extends Node ## Two-process loopback test with the real match view (LuneGame) on both sides. ## godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=host --rounds=4 ## godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=client --name=Gast ## Options: --port=, --address=, --rounds=, --drop-round=N (client leaves during combat N), ## --shots=DIR (with a window: the guest saves planning and combat views of round 1 and 2). ## tools/luneraria/net_rounds.py starts the processes and compares their round checksums. ## This is a local loopback test, not a six-player internet match. const Session = preload("res://scripts/luneraria/net/lune_session.gd") const Game = preload("res://scripts/luneraria/lune_game.gd") ## Extra host steps per frame at 60 FPS: a round takes seconds, snapshots still flow. ## --steps=0 keeps the real-time combat clock (visual checks). var steps_per_frame: int = 8 var role: String = "host" var port: int = 24571 var address: String = "127.0.0.1" var guest_name: String = "Gast" var rounds: int = 4 var drop_round: int = -1 var session: Node var game: Node3D var started_msec: int = 0 var planned_round: int = 0 var frames_at_combat_start: int = 0 var shots_dir: String = "" var _shots_taken: Dictionary = {} func _ready() -> void: for arg: String in OS.get_cmdline_user_args(): var value: String = arg.get_slice("=", 1) match arg.get_slice("=", 0): "--role": role = value "--port": port = int(value) "--address": address = value "--name": guest_name = value "--rounds": rounds = int(value) "--drop-round": drop_round = int(value) "--shots": shots_dir = value "--steps": steps_per_frame = int(value) started_msec = Time.get_ticks_msec() Engine.max_fps = 60 session = Session.new() session.name = "LuneSession" add_child(session) session.session_message.connect(func(text: String, is_error: bool) -> void: print("NET_MSG ", "ERR " if is_error else "OK ", text)) session.phase_announced.connect(_on_phase) call_deferred("start") func start() -> void: if role == "host": var options := {"seed": 42, "human_slot": 0, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]} var err: int = session.host_enet(port, options, "Host") print("NET_HOST ", "OK" if err == OK else session.last_error) if err != OK: get_tree().quit(1) return _launch() else: session.host_lost.connect(func() -> void: print("NET_HOST_LOST ", session.last_error) get_tree().quit(3)) session.match_resetup.connect(func() -> void: print("NET_SETUP seat=", session.local_slot, " round=", session.match_state.round_number, " phase=", session.match_state.phase) _launch()) var err: int = session.join_enet(address, port, guest_name) print("NET_JOIN ", "OK" if err == OK else session.last_error) if err != OK: get_tree().quit(1) func _launch() -> void: if is_instance_valid(game): game.free() game = Game.new() add_child(game) game.session = session game.start({"seed": session.match_state.seed_value, "human_slot": session.local_slot}) if role == "client": # Guard: a guest must never act for another seat, even through the host API. var spoof: Dictionary = session.host_command(1, 5, {"type": "buy", "type_id": "w01"}) print("NET_SPOOF ", "REJECTED" if not spoof.ok else "ACCEPTED-BAD") ## Public, committed state; host and guest must agree on it after every settlement. static func checksum(match_state: LuneMatch) -> String: var parts: PackedStringArray = [] for player: Dictionary in match_state.players: var hero: Dictionary = player.hero parts.append("%d:%d:%d:%d" % [int(player.slot), int(player.gold), (player.roster as Array).size(), 0 if hero.is_empty() else int(hero.xp)]) var ids: Array = match_state.building_hp.keys() ids.sort() for id: String in ids: parts.append("%s=%.1f" % [id, float(match_state.building_hp[id])]) parts.append("boss=%d" % int(match_state.boss.get("next_available_round", 0))) parts.append("winner=%d" % match_state.winner) return str(hash("|".join(parts))) func _on_phase(phase: int, round_number: int) -> void: if phase == LuneMatch.Phase.COMBAT: frames_at_combat_start = session.frames_received return if round_number <= 1 and phase != LuneMatch.Phase.OVER: return # The settlement of the previous combat is now visible on this side. var fought: int = round_number - 1 if phase == LuneMatch.Phase.PLANNING else round_number print("NET_ROUND role=%s round=%d phase=%d sum=%s" % [role, fought, phase, checksum(session.match_state)]) if role == "client" and session.match_state.combat != null: print("NET_CLIENT_COMBAT round=%d frames=%d local_steps=%d" % [fought, session.frames_received - frames_at_combat_start, session.match_state.combat.steps]) func _process(_delta: float) -> void: if Time.get_ticks_msec() - started_msec > 300000: print("NET_TIMEOUT role=", role) get_tree().quit(2) return if not is_instance_valid(game) or game.match_state == null: return var state: LuneMatch = game.match_state if state.phase == LuneMatch.Phase.OVER: _quit_soon("over round=%d winner=%d" % [state.round_number, state.winner]) return if state.phase == LuneMatch.Phase.PLANNING: if state.round_number > rounds: _quit_soon("round=%d seat=%d" % [state.round_number, session.local_slot]) return if role == "client" and not shots_dir.is_empty() and not _shot_once("planning-r%d" % state.round_number, 20): return _plan(state) elif state.phase == LuneMatch.Phase.COMBAT: if role == "client" and not shots_dir.is_empty() and session.frames_received > frames_at_combat_start + 60: if _shot_once("combat-r%d" % state.round_number, 0) and state.round_number == 1: game.focus_lane(state.players[session.local_slot].lane, true) if role == "host": # Faster combat clock on the host only; the rules stay unchanged. for i: int in range(steps_per_frame): if state.phase != LuneMatch.Phase.COMBAT: break state.step_combat() elif drop_round == state.round_number and session.frames_received > frames_at_combat_start: # Leave on the first snapshot of the combat: short combats may send only a few. print("NET_CLIENT_DROP round=%d seat=%d" % [state.round_number, session.local_slot]) session.close() get_tree().quit(0) var _quitting: bool = false var _shot_wait: Dictionary = {} ## Saves one guest screenshot per label after a few frames of settling; true once taken. func _shot_once(label: String, settle_frames: int) -> bool: if _shots_taken.has(label) or int(label.right(1)) > 2: return true _shot_wait[label] = int(_shot_wait.get(label, 0)) + 1 if int(_shot_wait[label]) < settle_frames: return false var image: Image = get_viewport().get_texture().get_image() DirAccess.make_dir_recursive_absolute(shots_dir) image.save_png(shots_dir.path_join("net-guest-%s.png" % label)) _shots_taken[label] = true print("NET_SHOT ", label) return true ## The host lingers briefly so the last state reaches the guest before the socket closes. func _quit_soon(note: String) -> void: if _quitting: return _quitting = true print("NET_%s_DONE %s" % [role.to_upper(), note]) await get_tree().create_timer(1.0 if role == "host" else 0.1).timeout session.close() get_tree().quit(0) ## Buys one unit and confirms once per planning phase, through the real game controls. ## The host waits for a seated guest in every planning phase (covers the rejoin case). func _plan(state: LuneMatch) -> void: var slot: int = session.local_slot if slot < 0 or planned_round == state.round_number or bool(state.players[slot].ready): return if role == "host" and session.seat_peers.size() < 2: return planned_round = state.round_number var ids: Array[String] = state.base_ids(state.players[slot].bund) var gold: int = state.gold_of(slot) var affordable: Array = ids.filter(func(id: String) -> bool: return int(DataCatalog.get_unit(id).cost) <= gold) if not affordable.is_empty(): game._on_command({"type": "buy", "type_id": affordable[state.round_number % affordable.size()]}) game._on_ready() print("NET_PLAN role=%s round=%d seat=%d" % [role, state.round_number, slot])