- 新增 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
162 lines
6.6 KiB
GDScript
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")
|