Files
mtgodot-poc/project/net_world_vis_test.gd
T
shenleiandClaude Opus 5 f39a55fdd5 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
2026-09-11 22:06:48 +09:00

162 lines
6.6 KiB
GDScript

# net_world_vis_test —— CLIENT-GAP §2.1 两层模型 / 可见性剔除的 headless 自检。
# godot --headless --path project --script net_world_vis_test.gd
# 用带 pos_cm 的假 client 驱动 net_world.gd 的每帧可见性管理:
# - 数据层始终有全部实体;场景节点只在进入 CHAR_STAGE_VIEW_BOUND 时创建
# - 主角走近 -> 远端实体补建;主角走远 -> 节点淡出(数据保留)
# - AFFECT_SHOW_ALWAYS / IsWall 的实体无视距离常驻
# 退出码 0 = 全过。
extends SceneTree
const NetWorld = preload("res://net_world.gd")
const BOUND := 200 * 100 # CHAR_STAGE_VIEW_BOUND
const NEAR_CM := Vector2(100 * 100, 0) # 10000 cm < BOUND
const FAR_CM := Vector2(500 * 100, 0) # 50000 cm > BOUND + 10
class FakeClient extends Node:
signal entity_spawned(entity: Dictionary)
signal entity_despawned(vid: int)
signal entity_moved(vid: int)
signal entity_main_set(vid: int)
signal entity_info(vid: int, entity: Dictionary)
signal chat(type: int, vid: int, text: String)
signal vitals_changed(vid: int)
signal entity_dead(vid: int)
signal damage(vid: int, amount: int, flag: int)
var ents := {}
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func get_pvp_relations() -> Array: return []
func get_duel() -> Dictionary: return {}
func add(vid: int, cm: Vector2, extra := {}) -> Dictionary:
var d := {"vid": vid, "name": "e%d" % vid, "is_main": false,
"func": 0, "moving": false, "angle_deg": 0.0,
"hp": 100, "max_hp": 100, "dead": false, "race": 0,
"affect_flags": 0, "parts": [0, 0, 0, 0],
"pos": Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01),
"pos_cm": Vector3(cm.x, cm.y, 0.0)}
d.merge(extra, true)
ents[vid] = d
return d
func move_main(cm: Vector2) -> void:
ents[1]["pos_cm"] = Vector3(cm.x, cm.y, 0.0)
ents[1]["pos"] = Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01)
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: net_world_vis_test (§2.1 two-layer visibility cull / re-show)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var mount := Node3D.new()
get_root().add_child(mount)
var fc := FakeClient.new()
get_root().add_child(fc)
var nw: Node = NetWorld.new()
get_root().add_child(nw)
nw.setup(fc, mount)
var added := {}
var removed := {}
nw.entity_added.connect(func(_n, v): added[v] = int(added.get(v, 0)) + 1)
nw.entity_removed.connect(func(v): removed[v] = int(removed.get(v, 0)) + 1)
# main character at origin
fc.add(1, Vector2.ZERO, {"is_main": true})
fc.entity_main_set.emit(1)
# 1) a near entity spawns a node immediately (signal path, in view)
var near := fc.add(2, NEAR_CM)
fc.entity_spawned.emit(near)
_ck(nw.node_for(2) != null, "near entity (10000cm) -> node built on spawn")
# 2) a far entity is data-only: the spawn signal must NOT build a node
var far := fc.add(3, FAR_CM)
fc.entity_spawned.emit(far)
_ck(nw.node_for(3) == null, "far entity (50000cm) -> spawn signal builds no node (§2.1)")
await process_frame
_ck(nw.node_for(3) == null, "far entity still nodeless after a visibility pass")
_ck(nw.node_for(2) != null, "near entity keeps its node while main is close")
# 3) main walks over to the far entity -> per-frame manager builds its node
fc.move_main(FAR_CM)
await process_frame
await process_frame
_ck(nw.node_for(3) != null, "main in range -> _update_visibility builds the far node")
_ck(int(added.get(3, 0)) == 1, "far node built exactly once")
# 4) entity 2 is now out of range -> node blends out, data row stays
_ck(nw.node_for(2) == null, "entity 2 out of range -> node culled")
_ck(int(removed.get(2, 0)) == 1, "cull emits entity_removed once")
_ck(fc.get_entity(2).has("vid"), "culled entity still present in the data layer")
await process_frame
_ck(nw.node_for(2) == null, "culled entity is not rebuilt while it stays out of range")
# 5) AFFECT_SHOW_ALWAYS (bit 7) -> force-visible regardless of distance
var beacon := fc.add(4, FAR_CM + Vector2(90000, 0), {"affect_flags": 1 << 7})
fc.entity_spawned.emit(beacon)
_ck(nw.node_for(4) != null, "AFFECT_SHOW_ALWAYS entity -> node built though far")
await process_frame
_ck(nw.node_for(4) != null, "force-visible entity survives the cull loop")
# 6) IsWall race -> always mirrored
var wall := fc.add(5, FAR_CM + Vector2(120000, 0), {"race": 14202})
fc.entity_spawned.emit(wall)
await process_frame
_ck(nw.node_for(5) != null, "wall race (14202) -> node built and kept regardless of distance")
# 7) main walks back -> entity 2 comes into view again and is rebuilt
fc.move_main(NEAR_CM)
# entity 2's fade tween (0.3s) must finish before it can be rebuilt
await create_tween().tween_interval(0.5).finished
await process_frame
_ck(nw.node_for(2) != null, "main back in range -> culled entity rebuilt after its fade")
_ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry")
await _dead_motion(fc, nw)
class AnimView extends Node3D:
var states: Array[String] = []
func set_anim_state(s: String) -> void:
states.append(s)
# MAP-02 / CActorInstance::Die: a model plays NAME_DEAD only (no scene tilt), and
# __SetMotion rejects every non-dead motion while the actor is dead — the
# func/moving edge that arrives with the death must not restart WAIT.
func _dead_motion(fc: FakeClient, nw: Node) -> void:
nw.set_model_factory(func(d: Dictionary) -> Node3D:
return AnimView.new() if int(d.get("race", 0)) == 2301 else null)
var ent := fc.add(6, NEAR_CM + Vector2(300, 0), {"race": 2301, "ch_type": 2, "moving": true})
fc.entity_spawned.emit(ent)
await process_frame
var view := nw.node_for(6) as AnimView
_ck(view != null and view.states.back() == "run", "moving tree monster -> run requested")
if view == null:
return
ent["moving"] = false
ent["dead"] = true
fc.entity_dead.emit(6)
await create_tween().tween_interval(0.4).finished
await process_frame
_ck(view.states.back() == "dead", "death while moving keeps DEAD (reference __SetMotion ignores WAIT when dead): %s" % [view.states])
_ck(is_zero_approx(view.rotation.x) and is_zero_approx(view.rotation.z), "model with motions is not tilted on death (rotation.x=%.2f)" % view.rotation.x)
var capsule := fc.add(7, NEAR_CM + Vector2(600, 0), {"ch_type": 2})
fc.entity_spawned.emit(capsule)
var placeholder: Node3D = nw.node_for(7)
capsule["dead"] = true
fc.entity_dead.emit(7)
await create_tween().tween_interval(0.4).finished
_ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue")