Files
mtgodot-poc/project/netplay_test.gd
T

499 lines
22 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.3attack_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.3SHORSE::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.3PythonPlayerInputKeyboard::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_SKILLrange>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
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")
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")
# __ChangeTargetToPickedInstanceNPC(非战斗目标)-> 清 _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 :804targetBoard.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")