- 新增 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
506 lines
23 KiB
GDScript
506 lines
23 KiB
GDScript
# netplay_test —— P0 闭环胶水(net_play.gd)的 headless 自检。
|
||
# godot --headless --path project --script netplay_test.gd
|
||
# 用假 M2Client / 假 PlayerController / 假 HUD 驱动,断言 net_play 产生的调用正确。
|
||
# 退出码 0 = 全过。
|
||
extends SceneTree
|
||
|
||
const NetPlay = preload("res://net_play.gd")
|
||
const NetWorld = preload("res://net_world.gd")
|
||
const EntityRules = preload("res://entity_rules.gd")
|
||
|
||
class FakeClient extends Node:
|
||
signal entity_spawned(entity: Dictionary)
|
||
signal entity_despawned(vid: int)
|
||
signal entity_moved(vid: int)
|
||
signal entity_info(vid: int, entity: Dictionary)
|
||
signal entity_main_set(vid: int)
|
||
signal entity_dead(vid: int)
|
||
signal vitals_changed(vid: int)
|
||
signal points_changed(points: Dictionary)
|
||
signal target_info(vid: int, hp_percent: int)
|
||
signal observer_mode_changed(enabled: bool)
|
||
signal fishing_event(subheader: int, info: int, dir: int)
|
||
signal chat(type: int, vid: int, text: String)
|
||
var ents := {}
|
||
var main := 0
|
||
var in_game := true
|
||
var ground_items := []
|
||
var skills := []
|
||
var calls := {"move": [], "attack": [], "set_target": [], "click_npc": [],
|
||
"pickup": [], "fly": [], "fishing": []}
|
||
func is_in_game() -> bool: return in_game
|
||
func get_main_vid() -> int: return main
|
||
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
|
||
func get_entities() -> Array: return ents.values()
|
||
func move(f, a, rot, x, y) -> bool: calls.move.append([f, a, rot, x, y]); return true
|
||
func attack(motion, vid) -> bool: calls.attack.append([motion, vid]); return true
|
||
func set_target(vid) -> bool: calls.set_target.append(vid); return true
|
||
func click_npc(vid) -> bool: calls.click_npc.append(vid); return true
|
||
func pickup_item(vid) -> bool: calls.pickup.append(vid); return true
|
||
func add_fly_targeting(vid, x, y) -> bool: calls.fly.append([vid, x, y]); return true
|
||
func fishing(rot_deg) -> bool: calls.fishing.append(rot_deg); return true
|
||
func get_ground_items() -> Array: return ground_items
|
||
func get_skills() -> Array: return skills
|
||
|
||
class FakePC extends Node:
|
||
signal target_selected(node: Node3D)
|
||
signal moved(pos: Vector3)
|
||
signal anim_state(state: String)
|
||
var player: Node3D
|
||
var server_speed := 1.0
|
||
var walk_calls := []
|
||
var frozen := false
|
||
func set_server_speed(moving_speed: int) -> void: server_speed = float(moving_speed) / 100.0
|
||
func walk_to(p: Vector3) -> void: walk_calls.append(p)
|
||
|
||
class FakeHud extends Node:
|
||
var vitals := []
|
||
var exp_v := []
|
||
var level := 0
|
||
var target := []
|
||
var energy := []
|
||
var cleared := 0
|
||
func set_vitals(hp, mhp, sp, msp): vitals = [hp, mhp, sp, msp]
|
||
func set_exp(xp, nxp): exp_v = [xp, nxp]
|
||
func set_level(lv): level = lv
|
||
func set_energy(value, maximum): energy = [value, maximum]
|
||
func set_target(nm, pct): target = [nm, pct]
|
||
func clear_target(): cleared += 1
|
||
|
||
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: netplay_test (P0 move/attack/target/HUD loop)")
|
||
quit(0)
|
||
else:
|
||
printerr("%d check(s) failed" % _fail)
|
||
quit(1)
|
||
|
||
func _run() -> void:
|
||
var root := get_root()
|
||
var fc := FakeClient.new()
|
||
var pc := FakePC.new()
|
||
var hud := FakeHud.new()
|
||
var nw: Node = NetWorld.new()
|
||
pc.player = Node3D.new()
|
||
root.add_child(fc); root.add_child(pc); root.add_child(hud); root.add_child(nw)
|
||
root.add_child(pc.player)
|
||
var np: Node = NetPlay.new()
|
||
root.add_child(np)
|
||
np.setup(fc, pc, nw, hud)
|
||
|
||
fc.main = 1000
|
||
fc.ents[1000] = {"vid": 1000, "pos": Vector3.ZERO, "hp": 200, "max_hp": 200,
|
||
"moving_speed": 150, "attack_speed": 150}
|
||
fc.entity_main_set.emit(1000)
|
||
fc.entity_info.emit(1000, fc.ents[1000])
|
||
_ck(is_equal_approx(pc.server_speed, 1.5), "GC_CHANGE_SPEED -> local prediction speed scale")
|
||
# §3.3:attack_speed 只喂攻速系数(缩放播放速率),不再在收包时把 period 直接写死
|
||
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "entity attack_speed -> attack-speed factor")
|
||
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
|
||
"no motion data -> cadence = DEFAULT_ATTACK_PERIOD / factor")
|
||
|
||
# §5.3:SHORSE::CanAttack / CPythonPlayer::__CanAttack 的坐骑等级门。
|
||
# 普通坐骑 level 1 不能攻击;新坐骑还需要骑乘术 level 11 或 master。
|
||
fc.skills = [{"id": 109, "level": 1, "master": 0}]
|
||
fc.ents[1000]["mount_vnum"] = 20101
|
||
_ck(not np._can_attack_horse_level(), "horse level 1 -> CanAttackHorseLevel false")
|
||
fc.ents[1000]["mount_vnum"] = 20205
|
||
_ck(np._can_attack_horse_level(), "horse level 2 -> CanAttackHorseLevel true")
|
||
_ck(not np._can_attack(), "new horse skill below level 11 -> attack blocked")
|
||
fc.skills[0]["level"] = 11
|
||
_ck(np._can_attack(), "new horse skill level 11 -> attack allowed")
|
||
fc.ents[1000]["mount_vnum"] = 0
|
||
fc.skills = []
|
||
|
||
# §5.3:PythonPlayerInputKeyboard::SetAttackKeyState 在 FISHING mode
|
||
# 改走 NEW_Fishing,不设置普通攻击键;GC_FISHING 到达后方向键/地面点击
|
||
# 走 NEW_CancelFishing,且 500ms 内不重复发取消包。
|
||
np.combo_motion_mode = np.MOTION_MODE_FISHING
|
||
pc.player.rotation.y = 0.0
|
||
fc.calls.fishing.clear()
|
||
np.set_attack_key(true)
|
||
_ck(fc.calls.fishing.size() == 1 and is_equal_approx(float(fc.calls.fishing[0]), 270.0),
|
||
"fishing attack key -> CG_FISHING with MapCoord heading")
|
||
_ck(not np._attack_key_down, "fishing attack key does not enable normal attack")
|
||
fc.fishing_event.emit(0, 1000, 0)
|
||
_ck(np.is_fishing(), "GC_FISHING START -> IsFishing state")
|
||
_ck(np.cancel_fishing(), "NEW_CancelFishing sends first cancel")
|
||
_ck(fc.calls.fishing.size() == 2 and is_equal_approx(float(fc.calls.fishing[1]), 0.0),
|
||
"cancel fishing -> CG_FISHING(0)")
|
||
_ck(not np.cancel_fishing(), "NEW_CancelFishing throttles within 500ms")
|
||
np.combo_motion_mode = np.MOTION_MODE_GENERAL
|
||
|
||
# 1) 点地移动 -> move(FUNC_MOVE) 带 server cm + heading
|
||
pc.player.position = Vector3(3, 0, -5) # to_server_cm -> (300, -500)
|
||
pc.player.rotation.y = 0.0
|
||
pc.moved.emit(pc.player.position)
|
||
_ck(fc.calls.move.size() == 1, "one move sent")
|
||
if fc.calls.move.size() == 1:
|
||
var mv: Array = fc.calls.move[0]
|
||
_ck(mv[0] == 1, "func == FUNC_MOVE")
|
||
_ck(mv[3] == 300 and mv[4] == -500, "server cm (300,-500), got (%d,%d)" % [mv[3], mv[4]])
|
||
_ck(mv[2] >= 0 and mv[2] < 360, "heading in [0,360)")
|
||
|
||
# 2) 节流:紧接着的小位移不发
|
||
pc.player.position = Vector3(3.3, 0, -5)
|
||
pc.moved.emit(pc.player.position)
|
||
_ck(fc.calls.move.size() == 1, "small quick move throttled")
|
||
|
||
# 3) 停下 -> move(FUNC_WAIT) 一次
|
||
pc.anim_state.emit("wait")
|
||
_ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == 0, "stop -> FUNC_WAIT sent")
|
||
pc.anim_state.emit("wait")
|
||
_ck(fc.calls.move.size() == 2, "second wait not re-sent")
|
||
|
||
# 4) 点实体 -> set_target
|
||
var mob := Node3D.new()
|
||
mob.set_meta("vid", 2000)
|
||
root.add_child(mob)
|
||
mob.position = Vector3(4, 0, -5) # 距玩家(3.3,0,-5) 0.7m -> 攻击距离内
|
||
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "name": "Wolf", "pos": mob.position, "hp": 50, "max_hp": 50, "dead": false}
|
||
nw._by_vid[2000] = mob # 让 net_world.node_for(2000) 命中
|
||
pc.target_selected.emit(mob)
|
||
_ck(fc.calls.set_target.size() == 1 and fc.calls.set_target[0] == 2000, "set_target(2000)")
|
||
|
||
# NPC/传送点/石头走 click_npc,而不是被误当成攻击目标;这是 Cube
|
||
# 窗口由服务端 cube_open 命令打开前的入口。
|
||
var npc := Node3D.new()
|
||
npc.set_meta("vid", 3000)
|
||
root.add_child(npc)
|
||
fc.ents[3000] = {"vid": 3000, "ch_type": 1, "name": "Cube NPC", "pos": npc.position}
|
||
pc.target_selected.emit(npc)
|
||
_ck(fc.calls.click_npc == [3000], "NPC selection -> click_npc(3000)")
|
||
# §5.4 / §8.8:木门不应退化为 PC;它可进入 CanViewTargetHP / 攻击分类。
|
||
_ck(np._entity_kind({"vid": 3001, "race": 13000}) == EntityRules.KIND_WOODEN_DOOR,
|
||
"race 13000 -> WOODEN_DOOR")
|
||
_ck(np._entity_kind({"vid": 3002, "kind": EntityRules.KIND_BUILDING}) == EntityRules.KIND_BUILDING,
|
||
"explicit BUILDING kind -> BUILDING")
|
||
|
||
# 5) 只有按住 SPACE(参考端 m_isAtkKey)才在攻击距离内 attack
|
||
np.set_attack_key(true)
|
||
for i in 4: await process_frame
|
||
_ck(fc.calls.attack.size() >= 1, "space-held attack fired, got %d" % fc.calls.attack.size())
|
||
if fc.calls.attack.size() >= 1:
|
||
_ck(fc.calls.attack[0][1] == 2000, "attack victim == 2000")
|
||
var n1: int = fc.calls.attack.size()
|
||
await process_frame
|
||
_ck(fc.calls.attack.size() == n1, "attack throttled within period")
|
||
np.set_attack_key(false)
|
||
|
||
# 5b) §3.4 预约动作状态机(__ReserveProcess_ClickActor 的 10 步顺序)
|
||
# 步骤 2:目标在可点击距离外 -> 继续靠近(walk_to),不发攻击
|
||
pc.walk_calls.clear()
|
||
fc.calls.attack.clear()
|
||
mob.position = Vector3(40, 0, -5) # 距玩家 ~37m,远超 1.5m 默认可点击距离
|
||
np.set_attack_key(true)
|
||
for i in 3: await process_frame
|
||
_ck(pc.walk_calls.size() >= 1, "out-of-range reserved target -> walk_to (approach)")
|
||
_ck(fc.calls.attack.is_empty(), "out-of-range -> no attack packet")
|
||
_ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ACTOR, "reservation kept while approaching")
|
||
|
||
# 步骤 10:进入距离 -> 攻击且预约清空
|
||
mob.position = Vector3(4, 0, -5)
|
||
np._attack_cd = 0.0
|
||
fc.calls.attack.clear()
|
||
await process_frame
|
||
_ck(fc.calls.attack.size() >= 1, "in-range reserved target -> attack fired")
|
||
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "reservation cleared after swing")
|
||
|
||
# 步骤 5:自己在安全区 -> OnCannotAttack('IN_SAFE') + 清预约,不攻击
|
||
var cannot: Array = []
|
||
np.cannot_act.connect(func(code): cannot.append(code))
|
||
fc.ents[1000]["in_safe"] = true
|
||
np._attack_cd = 0.0
|
||
fc.calls.attack.clear()
|
||
for i in 2: await process_frame
|
||
_ck(cannot.has("IN_SAFE"), "main in safe zone -> cannot_act('IN_SAFE')")
|
||
_ck(fc.calls.attack.is_empty(), "safe zone -> no attack packet")
|
||
fc.ents[1000]["in_safe"] = false
|
||
|
||
# __Update_AutoAttack:目标死亡 -> 清 auto-attack + 目标
|
||
fc.ents[2000]["dead"] = true
|
||
np._attack_cd = 0.0
|
||
await process_frame
|
||
_ck(np._auto_attack_vid == 0, "dead reserved target -> auto-attack cleared")
|
||
fc.ents[2000]["dead"] = false
|
||
np.set_attack_key(false)
|
||
np._clear_reserved()
|
||
|
||
# 5c) §3.4 增量 91 —— CLICK_ITEM / CLICK_POSITION / USE_SKILL 预约分派
|
||
# + __ChangeTargetToPickedInstance + 扇形/圆形补 fly-targeting
|
||
np._target_vid = 0
|
||
np._auto_attack_vid = 0
|
||
np.set_attack_key(false)
|
||
np._clear_reserved()
|
||
pc.player.position = Vector3.ZERO
|
||
pc.player.rotation.y = 0.0
|
||
pc.walk_calls.clear()
|
||
fc.calls.move.clear()
|
||
|
||
# CLICK_ITEM:远处掉落物 -> walk_to;到手 -> pickup + FUNC_WAIT + 清预约
|
||
fc.ground_items = [{"vid": 777, "vnum": 1, "owner": "", "pos": Vector3(5, 0, 5)}] # to_world -> (5,0,-5)
|
||
np.reserve_click_item(777)
|
||
await process_frame
|
||
_ck(pc.walk_calls.size() >= 1, "CLICK_ITEM far -> walk_to")
|
||
_ck(np._reserved_mode == NetPlay.ReservedMode.CLICK_ITEM, "CLICK_ITEM reservation kept while approaching")
|
||
pc.player.position = Vector3(5, 0, -5) # 站到物品上
|
||
var mv_n: int = fc.calls.move.size()
|
||
await process_frame
|
||
_ck(fc.calls.pickup == [777], "CLICK_ITEM in-range -> pickup_item(777)")
|
||
_ck(fc.calls.move.size() >= mv_n + 1 and fc.calls.move[mv_n][0] == np.FUNC_WAIT,
|
||
"CLICK_ITEM in-range -> FUNC_WAIT state packet")
|
||
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_ITEM cleared after pickup")
|
||
|
||
# CLICK_ITEM:物品已消失 -> 清预约,不走
|
||
pc.walk_calls.clear()
|
||
fc.ground_items = []
|
||
np.reserve_click_item(888)
|
||
await process_frame
|
||
_ck(pc.walk_calls.is_empty() and np._reserved_mode == NetPlay.ReservedMode.NONE,
|
||
"CLICK_ITEM missing item -> reservation cleared")
|
||
|
||
# CLICK_POSITION:延时未到不动;延时耗尽 -> walk_to + 清预约
|
||
pc.walk_calls.clear()
|
||
np.reserve_click_ground(Vector3(9, 0, -2))
|
||
_ck(np._reserved_delay > 0.0, "reserve_click_ground sets a reserved delay")
|
||
np._reserved_delay = 999.0
|
||
await process_frame
|
||
_ck(pc.walk_calls.is_empty(), "CLICK_POSITION waits out the reserved delay")
|
||
np._reserved_delay = 0.0
|
||
await process_frame
|
||
_ck(pc.walk_calls.size() == 1 and pc.walk_calls[0] == Vector3(9, 0, -2),
|
||
"CLICK_POSITION delay elapsed -> walk_to(target)")
|
||
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "CLICK_POSITION cleared after move")
|
||
|
||
# USE_SKILL:range>100 存 -10;射程外 walk_to;射程内 -> hook + set_target + 清预约
|
||
var smob := Node3D.new()
|
||
smob.set_meta("vid", 4100)
|
||
root.add_child(smob)
|
||
nw._by_vid[4100] = smob
|
||
smob.position = Vector3(30, 0, 0)
|
||
fc.ents[4100] = {"vid": 4100, "ch_type": 2, "pos": smob.position, "hp": 80, "max_hp": 80, "dead": false, "race": 101}
|
||
var hook_calls: Array = []
|
||
np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true
|
||
pc.player.position = Vector3.ZERO
|
||
_ck(is_equal_approx(np.skill_target_distance_cm(4100), 3000.0), "skill_target_distance_cm = node distance in cm")
|
||
_ck(np.skill_target_distance_cm(424242) < 0.0, "skill_target_distance_cm unknown vid -> -1")
|
||
var ctx_distance: Callable = np.skill_context().get("target_distance", Callable())
|
||
_ck(ctx_distance.is_valid() and is_equal_approx(float(ctx_distance.call(4100)), 3000.0),
|
||
"skill_context exposes the same distance to the PlayerSkill gate")
|
||
_ck(not np.is_use_skill_reserved(5), "no reservation yet")
|
||
np.reserve_use_skill(4100, 5, 800.0)
|
||
_ck(is_equal_approx(np._skill_range_reserved, 790.0), "reserve_use_skill trims range >100 by 10")
|
||
_ck(np.is_use_skill_reserved(5) and not np.is_use_skill_reserved(6), "__IsReservedUseSkill matches the reserved slot")
|
||
pc.walk_calls.clear()
|
||
fc.calls.set_target.clear()
|
||
await process_frame
|
||
_ck(pc.walk_calls.size() >= 1, "USE_SKILL out of range -> walk_to")
|
||
_ck(hook_calls.is_empty(), "USE_SKILL out of range -> no cast")
|
||
smob.position = Vector3(3, 0, 0) # 3m < 7.9m 射程
|
||
await process_frame
|
||
_ck(hook_calls == [5], "USE_SKILL in range -> use_skill_hook(slot 5)")
|
||
_ck(fc.calls.set_target.has(4100), "USE_SKILL in range -> set_target(4100)")
|
||
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL cleared after cast")
|
||
np.reserve_use_skill(999999, 5, 300.0)
|
||
await process_frame
|
||
_ck(np._reserved_mode == NetPlay.ReservedMode.NONE, "USE_SKILL missing target -> cleared")
|
||
|
||
# __ChangeTargetToPickedInstance:NPC(非战斗目标)-> 清 _target_vid + set_target(0)
|
||
np._target_vid = 2000
|
||
fc.calls.set_target.clear()
|
||
fc.ents[5000] = {"vid": 5000, "ch_type": 1, "name": "NPC"}
|
||
var ok_npc: bool = np._change_target_to_picked_instance(5000)
|
||
_ck(not ok_npc and np._target_vid == 0 and fc.calls.set_target == [0],
|
||
"_change_target_to_picked_instance(NPC) -> target cleared")
|
||
fc.calls.set_target.clear()
|
||
var ok_mob: bool = np._change_target_to_picked_instance(4100)
|
||
_ck(ok_mob and np._target_vid == 4100 and fc.calls.set_target == [4100],
|
||
"_change_target_to_picked_instance(monster) -> target set")
|
||
|
||
# send_fly_targeting —— 把早前实体挪远(不销毁,后续用例还引用),铺一组已知几何
|
||
np._target_vid = 0
|
||
np._clear_reserved()
|
||
pc.player.position = Vector3.ZERO
|
||
for v in [2000, 4100]:
|
||
if nw._by_vid.has(v):
|
||
nw._by_vid[v].position = Vector3(9999, 0, 9999)
|
||
if fc.ents.has(v):
|
||
fc.ents[v]["pos"] = Vector3(9999, 0, 9999)
|
||
var mk := func(v: int, p: Vector3) -> void:
|
||
var n := Node3D.new()
|
||
n.set_meta("vid", v)
|
||
n.position = p
|
||
root.add_child(n)
|
||
nw._by_vid[v] = n
|
||
fc.ents[v] = {"vid": v, "ch_type": 2, "pos": p, "hp": 50, "max_hp": 50, "dead": false, "race": 101}
|
||
mk.call(4200, Vector3(0, 0, -10)) # 主目标,正前方 (-Z)
|
||
mk.call(4201, Vector3(2, 0, -10)) # 侧翼,扇内
|
||
mk.call(4202, Vector3(-2, 0, -10)) # 侧翼,扇内
|
||
mk.call(4203, Vector3(0, 0, 12)) # 背后,扇外
|
||
|
||
fc.calls.fly.clear()
|
||
var extras: int = np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.FAN)
|
||
_ck(extras == 2, "FAN send_fly_targeting: 2 extras (max 3 - primary), got %d" % extras)
|
||
var fly_vids := []
|
||
for f in fc.calls.fly:
|
||
fly_vids.append(f[0])
|
||
_ck(fly_vids.size() == 2 and not fly_vids.has(4200), "FAN: 2 extra packets, primary not re-sent")
|
||
_ck(not fly_vids.has(4203), "FAN: target-behind instance excluded from fan")
|
||
_ck(4201 in fly_vids and 4202 in fly_vids, "FAN: both flankers picked")
|
||
|
||
fc.calls.fly.clear()
|
||
var extras_c: int = np.send_fly_targeting(4200, 2500.0, 4, NetPlay.FlyShape.CIRCLE)
|
||
_ck(extras_c == 3, "CIRCLE: 3 extras (max 4 - primary), got %d" % extras_c)
|
||
var cvids := []
|
||
for f in fc.calls.fly:
|
||
cvids.append(f[0])
|
||
_ck(4203 in cvids, "CIRCLE: behind-target instance included (no fan filter)")
|
||
|
||
fc.calls.fly.clear()
|
||
var extras_r: int = np.send_fly_targeting(4200, 2500.0, 6, NetPlay.FlyShape.FAN)
|
||
_ck(extras_r == 5, "FAN shortfall: 5 extras for max 6, got %d" % extras_r)
|
||
var zero_fills := 0
|
||
for f in fc.calls.fly:
|
||
if f[0] == 0:
|
||
zero_fills += 1
|
||
_ck(zero_fills >= 1, "FAN shortfall: random vid=0 positions fill the remainder")
|
||
_ck(np.send_fly_targeting(4200, 2500.0, 1, NetPlay.FlyShape.FAN) == 0, "max_count<=1 -> no extras")
|
||
_ck(np.send_fly_targeting(4200, 2500.0, 3, NetPlay.FlyShape.SINGLE) == 0, "SINGLE shape -> no extras")
|
||
np._clear_reserved()
|
||
np._target_vid = 0
|
||
|
||
# 6) points_changed -> HUD
|
||
fc.points_changed.emit({"hp": 120, "max_hp": 200, "sp": 30, "max_sp": 80,
|
||
"exp": 50, "next_exp": 100, "level": 7, "energy": 75})
|
||
_ck(hud.vitals == [120, 200, 30, 80], "hud.set_vitals from points")
|
||
_ck(hud.exp_v == [50, 100], "hud.set_exp")
|
||
_ck(hud.level == 7, "hud.set_level")
|
||
_ck(hud.energy == [75, 100], "hud.set_energy from POINT_ENERGY")
|
||
|
||
# 7) 自己的 GC_MOVE 位置校正(远 -> 瞬移)
|
||
fc.ents[1000]["pos"] = Vector3(50, 0, -5)
|
||
fc.entity_moved.emit(1000)
|
||
_ck(pc.player.position.distance_to(Vector3(50, 0, pc.player.position.z)) < 0.01,
|
||
"far server pos -> snap, got %s" % pc.player.position)
|
||
|
||
# 8) target_info -> hud.set_target
|
||
fc.target_info.emit(2000, 40)
|
||
_ck(hud.target.size() == 2 and hud.target[1] == 40, "hud.set_target(pct=40)")
|
||
|
||
# 8b) §8.4「看他国玩家目标框」前置过滤(uitarget.py:189-193 / target_board.hidden_as_other_empire)
|
||
fc.ents[1000]["empire"] = 1
|
||
fc.ents[2500] = {"vid": 2500, "ch_type": 0, "name": "Enemy", "empire": 2,
|
||
"pos": Vector3(5, 0, 5), "hp": 100, "max_hp": 100, "dead": false}
|
||
# 开关开(默认 true):异阵营 PC 目标框照旧走 CLOSE_IF_DIFFERENT,框上正是该 VID -> 保留
|
||
np._target_vid = 2500
|
||
fc.target_info.emit(2500, 60)
|
||
_ck(np._target_vid == 2500, "看他国框开 -> 异阵营 PC 目标框保留")
|
||
# 开关关:异阵营 PC -> 前置过滤直接清目标框
|
||
var cleared_n: int = hud.cleared
|
||
np.set_view_other_empire_target(false)
|
||
np._target_vid = 2500
|
||
fc.target_info.emit(2500, 60)
|
||
_ck(np._target_vid == 0 and hud.cleared == cleared_n + 1, "看他国框关 -> 异阵营 PC 目标框被前置过滤清掉")
|
||
# 开关关不影响怪(empire 0 -> IsSameEmpire 为真)
|
||
np._target_vid = 0
|
||
fc.target_info.emit(2000, 30)
|
||
_ck(hud.target[1] == 30 and np._target_vid == 2000, "看他国框关不影响怪目标(HP 血条照设)")
|
||
np.set_view_other_empire_target(true)
|
||
fc.ents[1000].erase("empire")
|
||
fc.ents.erase(2500)
|
||
# 收尾:_target_vid 落在 2000(与原 8 一致),供 9) 目标死亡清框
|
||
|
||
# 8c) seam ⑫ 收口 —— game.py SetPCTargetBoard :804:targetBoard.Open 之后,若按住
|
||
# LCONTROL(:807)且同阵营(:809 IsSameEmpire)且非本人(:812)且非 building
|
||
# (:814)-> interface.OpenWhisperDialog(name)。poc 侧 net_play 发 whisper_requested,
|
||
# game_scene 接到 chat.start_whisper()。C++ 只对 PC 分派 SetPCTargetBoard。
|
||
var whispers: Array = []
|
||
np.whisper_requested.connect(func(nm: String): whispers.append(nm))
|
||
fc.ents[1000]["empire"] = 1
|
||
var bob := Node3D.new()
|
||
bob.set_meta("vid", 6100)
|
||
bob.position = Vector3(4, 0, -5)
|
||
root.add_child(bob)
|
||
nw._by_vid[6100] = bob
|
||
fc.ents[6100] = {"vid": 6100, "ch_type": 0, "name": "Bob", "empire": 1,
|
||
"pos": bob.position, "hp": 100, "max_hp": 100, "dead": false}
|
||
# LCONTROL 没按住 -> 只普通选中,不开悄悄话
|
||
np._target_vid = 0
|
||
np.set_lcontrol_down(false)
|
||
pc.target_selected.emit(bob)
|
||
_ck(whispers.is_empty(), "无 LCONTROL 点 PC -> 不开悄悄话")
|
||
_ck(fc.calls.set_target.back() == 6100, "点 PC 仍照常选中(set_target 6100)")
|
||
# 按住 LCONTROL + 同阵营他人 PC -> whisper_requested("Bob")
|
||
np.set_lcontrol_down(true)
|
||
pc.target_selected.emit(bob)
|
||
_ck(whispers == ["Bob"], "LCONTROL 点同阵营他人 PC -> whisper_requested('Bob')")
|
||
# 异阵营 PC -> 不请求(:809 IsSameEmpire)
|
||
whispers.clear()
|
||
fc.ents[6100]["empire"] = 2
|
||
pc.target_selected.emit(bob)
|
||
_ck(whispers.is_empty(), "LCONTROL 点异阵营 PC -> 不开悄悄话")
|
||
fc.ents[6100]["empire"] = 1
|
||
# 怪(KIND_MONSTER)即使按住 LCONTROL 也不走(C++ 只对 PC 分派 SetPCTargetBoard)
|
||
whispers.clear()
|
||
var wolf2 := Node3D.new()
|
||
wolf2.set_meta("vid", 6200)
|
||
wolf2.position = Vector3(4, 0, -5)
|
||
root.add_child(wolf2)
|
||
nw._by_vid[6200] = wolf2
|
||
fc.ents[6200] = {"vid": 6200, "ch_type": 2, "name": "Wolf2", "empire": 0,
|
||
"pos": wolf2.position, "hp": 50, "max_hp": 50, "dead": false}
|
||
pc.target_selected.emit(wolf2)
|
||
_ck(whispers.is_empty(), "LCONTROL 点怪 -> 不开悄悄话(仅 PC 分派)")
|
||
np.set_lcontrol_down(false)
|
||
fc.ents[1000].erase("empire")
|
||
fc.ents.erase(6100); fc.ents.erase(6200)
|
||
nw._by_vid.erase(6100); nw._by_vid.erase(6200)
|
||
np._target_vid = 2000 # 复位到 8b 收尾态,供 9) 目标死亡清框
|
||
|
||
# 9) 目标死亡 -> 清目标
|
||
fc.ents[2000]["dead"] = true
|
||
fc.entity_dead.emit(2000)
|
||
_ck(hud.cleared >= 1, "target cleared on death")
|
||
|
||
# 10) 观战模式锁住本地移动/选目标,并清掉当前目标
|
||
fc.ents[2000]["dead"] = false
|
||
fc.observer_mode_changed.emit(true)
|
||
var move_n: int = fc.calls.move.size()
|
||
var target_n: int = fc.calls.set_target.size()
|
||
pc.player.position = Vector3(8, 0, -5)
|
||
pc.moved.emit(pc.player.position)
|
||
pc.target_selected.emit(mob)
|
||
_ck(fc.calls.move.size() == move_n and fc.calls.set_target.size() == target_n,
|
||
"observer mode blocks local movement and targeting")
|
||
fc.observer_mode_changed.emit(false)
|
||
|
||
# 11) §3.2 上行 6 回调:OnMove 立即发 + 重置节流窗、OnMoving 300ms 节流、
|
||
# OnStop 收尾、OnAttack 额外发 FUNC_COMBO、on_use_skill 发 FUNC_SKILL|motion。
|
||
fc.observer_mode_changed.emit(false)
|
||
np._observer_mode = false
|
||
np._was_moving = false
|
||
np._last_moving_sent_t = 0.0
|
||
fc.calls.move.clear()
|
||
pc.player.position = Vector3(1, 0, -1)
|
||
pc.moved.emit(pc.player.position) # OnMove
|
||
_ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == np.FUNC_MOVE, "OnMove -> immediate FUNC_MOVE")
|
||
pc.player.position = Vector3(1.5, 0, -1)
|
||
pc.moved.emit(pc.player.position) # OnMoving, throttled (<300ms)
|
||
_ck(fc.calls.move.size() == 1, "OnMoving within 300ms throttled")
|
||
np._last_moving_sent_t -= 1.0 # 让 300ms 窗口过期
|
||
pc.moved.emit(pc.player.position) # OnMoving, now allowed
|
||
_ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == np.FUNC_MOVE, "OnMoving after 300ms -> FUNC_MOVE")
|
||
pc.anim_state.emit("wait") # OnStop
|
||
_ck(fc.calls.move.size() == 3 and fc.calls.move[2][0] == np.FUNC_WAIT, "OnStop -> FUNC_WAIT")
|
||
fc.calls.move.clear()
|
||
np.on_use_skill(5, 2) # OnUseSkill
|
||
_ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == (np.FUNC_SKILL | 5) and fc.calls.move[0][1] == 2,
|
||
"on_use_skill -> FUNC_SKILL|motion + arg")
|