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>
187 lines
7.9 KiB
GDScript
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])
|