saumwacht-godot/tools/luneraria/net_pair.gd
Martin Stickelbroeck 8b17563814 G1: Drei-Lane-Layout mit Höhenfeld, Grotte, Aufmarschbereichen und MOBA-Kamera
Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform,
Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt
gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt
gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der
Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera
mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet,
neue Formen als Blockout für G2/G4.

Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38,
verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch
20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md.

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

188 lines
8 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:
# 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])