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>
188 lines
8 KiB
GDScript
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])
|