# 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") 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) 只有按住 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 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") # __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)") # 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")