saumwacht-godot/tools/luneraria/net_pair.gd
Martin Stickelbroeck c15533831a M4: Spiel läuft vollständig über die autoritative Sitzung; M4 pausiert
Das echte Spiel umging die Sitzung bisher bei Bereitschaft, Startaufstellung
und Rückgängig, ließ Bots den Gast-Sitz planen und den Gast selbst simulieren.
Jetzt rechnet nur der Host; Gäste zeigen den Kampf aus Momentaufnahmen.
Dazu Protokollversion, Wiedereinstieg über den Anzeigenamen, verbindliches
Bereit, Entwurfs-Privatsphäre und Bot-Planung einmal pro Runde.

Geprüft: test_lune_net 43/43, net_rounds.py 12/12 (5 Runden, Wiedereinstieg,
gleiche Prüfsummen), Regeln 91/91, M2 73/73, verify_m1 38/38, verify_m2 14/14.
Offen und eingefroren: OpenGL-Modus, nativer Windows-11-Test, Demo-Neubau.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:00:08 +02:00

187 lines
7.9 KiB
GDScript

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 + 5:
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])