saumwacht-godot/scripts/luneraria/net/lune_session.gd
Martin Stickelbroeck 214ed5a1cc M4: autoritative Host/Join-Sitzung, Protokolltests, Loopback und Demos
LuneSession hält den autoritativen Matchzustand; Clients senden nur
Absichten. Fremde Sitze, veraltete Befehle und Doppelkäufe sind geprüft,
Disconnect führt zur Bot-Übernahme statt zum Kernverlust. Exporte und
entpackbare Demos 0.16.0-m4 für Linux und Windows.
2026-09-28 00:57:02 +02:00

389 lines
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name LuneSession
extends Node
## Authoritative network session for Champions of Luneraria.
## Must live in the scene tree (RPC + MultiplayerAPI live on Node).
## The host owns LuneMatch and every simulation result. Clients send intents only.
## The peer id maps to exactly one seat; a client never chooses another player's slot.
## A disconnect hands the seat to a bot. It never destroys a core and never migrates the host.
## Offline runs the same code path as a local host with bot-filled free seats.
const DEFAULT_PORT: int = 24571
const MAX_CLIENTS: int = 5
const PROTOCOL: int = 3
enum Role { OFFLINE, HOST, CLIENT }
var role: int = Role.OFFLINE
var match_state: LuneMatch
var local_slot: int = -1
## slot -> peer id (1 = host). Absent slots are bots owned by the host.
var seat_peers: Dictionary = {}
## [{peer, slot, name, ready, bot}]
var lobby: Array[Dictionary] = []
var last_error: String = ""
var connected: bool = false
signal lobby_changed
signal match_resetup
signal session_message(text: String, is_error: bool)
signal phase_announced(phase: int, round_number: int)
signal combat_snapshot(units: Array)
func is_authority() -> bool:
return role != Role.CLIENT
func display_name_for(slot: int) -> String:
if match_state != null and slot >= 0 and slot < match_state.players.size():
return str(match_state.players[slot].name)
return "Slot %d" % slot
func seat_of_peer(peer: int) -> int:
for slot: int in seat_peers:
if int(seat_peers[slot]) == peer:
return slot
return -1
# ---------------------------------------------------------------- setup
func setup_match(options: Dictionary) -> void:
match_state = LuneMatch.new()
match_state.setup(options)
match_resetup.emit()
_announce_phase()
func _announce_phase() -> void:
if match_state == null:
return
phase_announced.emit(match_state.phase, match_state.round_number)
# ---------------------------------------------------------------- start modes
func host_offline(human_slot: int, options: Dictionary) -> void:
role = Role.OFFLINE
connected = true
local_slot = human_slot
seat_peers = {human_slot: 1} if human_slot >= 0 else {}
lobby = []
if human_slot >= 0:
lobby.append({"peer": 1, "slot": human_slot, "name": "Du", "ready": false, "bot": false})
for slot: int in range(6):
if slot != human_slot:
lobby.append({"peer": 0, "slot": slot, "name": "Bot %d" % slot, "ready": true, "bot": true})
options["human_slot"] = human_slot
setup_match(options)
func host_enet(port: int, options: Dictionary, display_name: String = "Host") -> Error:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, MAX_CLIENTS)
if err != OK:
last_error = "Server auf Port %d nicht startbar (%s)." % [port, error_string(err)]
return err
_bind_peer(peer)
role = Role.HOST
connected = true
var human: int = int(options.get("human_slot", 0))
local_slot = human
seat_peers = {human: 1}
lobby = [{"peer": 1, "slot": human, "name": display_name, "ready": false, "bot": false}]
options["human_slot"] = human
setup_match(options)
lobby_changed.emit()
return OK
func join_enet(address: String, port: int, display_name: String = "Gast") -> Error:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
if err != OK:
last_error = "Verbindung zu %s:%d nicht möglich (%s)." % [address, port, error_string(err)]
return err
_bind_peer(peer)
role = Role.CLIENT
connected = false
local_slot = -1
if not multiplayer.connected_to_server.is_connected(_on_connected):
multiplayer.connected_to_server.connect(_on_connected)
if not multiplayer.connection_failed.is_connected(_on_connection_failed):
multiplayer.connection_failed.connect(_on_connection_failed)
if not multiplayer.server_disconnected.is_connected(_on_server_lost):
multiplayer.server_disconnected.connect(_on_server_lost)
return OK
func _on_connected() -> void:
connected = true
session_message.emit("Mit dem Host verbunden.", false)
func _on_connection_failed() -> void:
last_error = "Verbindung zum Host fehlgeschlagen."
connected = false
session_message.emit(last_error, true)
func _on_server_lost() -> void:
last_error = "Host hat die Verbindung getrennt."
connected = false
session_message.emit(last_error, true)
func _bind_peer(peer: MultiplayerPeer) -> void:
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = peer
if not multiplayer.peer_disconnected.is_connected(_on_peer_disconnected):
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
if not multiplayer.peer_connected.is_connected(_on_peer_connected):
multiplayer.peer_connected.connect(_on_peer_connected)
func close() -> void:
if multiplayer != null and multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
role = Role.OFFLINE
connected = false
seat_peers.clear()
lobby.clear()
# ---------------------------------------------------------------- intents
func submit(command: Dictionary) -> Dictionary:
if match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
return {"ok": false, "message": "Keine Planung."}
if role == Role.CLIENT:
if local_slot < 0:
return {"ok": false, "message": "Kein Sitzplatz."}
rpc_id(1, "rpc_submit", local_slot, command)
return {"ok": true, "message": "Befehl gesendet."}
return host_command(1 if role == Role.HOST else 1, local_slot, command)
func ready_up() -> void:
if match_state == null:
return
if role == Role.CLIENT:
rpc_id(1, "rpc_ready", local_slot)
return
host_ready(local_slot)
## Testable host entry: peer must own slot. Duplicate submits of a purchase are
## rejected by LuneMatch through unique uids and gold, not by packet identity.
func host_command(peer: int, slot: int, command: Dictionary) -> Dictionary:
if not is_authority() or match_state == null:
return {"ok": false, "message": "Keine Autorität."}
if seat_of_peer(peer) < 0 or seat_of_peer(peer) != slot:
var rejected := {"ok": false, "message": "Fremder Sitz oder ungültige Einheiten-ID."}
if role == Role.HOST and peer > 1:
rpc_id(peer, "rpc_feedback", slot, rejected)
return rejected
if match_state.phase != LuneMatch.Phase.PLANNING:
var stale := {"ok": false, "message": "Veralteter Befehl (Planung vorbei)."}
if role == Role.HOST and peer > 1:
rpc_id(peer, "rpc_feedback", slot, stale)
return stale
var result: Dictionary = match_state.submit(slot, command)
if role == Role.HOST:
if peer > 1:
rpc_id(peer, "rpc_feedback", slot, result)
_broadcast_state()
return result
func host_ready(slot: int) -> void:
if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
return
match_state.set_ready(slot)
for entry: Dictionary in lobby:
if int(entry.slot) == slot:
entry.ready = true
_broadcast_lobby()
if match_state.all_ready():
host_begin_combat()
func plan_bot_seats() -> void:
if not is_authority() or match_state == null:
return
for slot: int in range(6):
if not seat_peers.has(slot):
LuneBot.plan(match_state, slot)
func host_begin_combat() -> void:
if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
return
plan_bot_seats()
match_state.begin_combat()
_announce_phase()
if role == Role.HOST:
rpc("rpc_phase", match_state.phase, match_state.round_number)
func host_finish_combat() -> void:
if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.COMBAT:
return
match_state.finish_combat()
_announce_phase()
if role == Role.HOST:
rpc("rpc_phase", match_state.phase, match_state.round_number)
_broadcast_state()
# ---------------------------------------------------------------- rpc
@rpc("any_peer", "call_remote", "reliable")
func rpc_submit(slot: int, command: Dictionary) -> void:
if not is_authority():
return
host_command(multiplayer.get_remote_sender_id(), slot, command)
@rpc("any_peer", "call_remote", "reliable")
func rpc_ready(slot: int) -> void:
if not is_authority():
return
var peer: int = multiplayer.get_remote_sender_id()
if seat_of_peer(peer) != slot:
return
host_ready(slot)
@rpc("authority", "call_remote", "reliable")
func rpc_feedback(_slot: int, result: Dictionary) -> void:
session_message.emit(str(result.get("message", "")), not bool(result.get("ok", false)))
@rpc("authority", "call_remote", "reliable")
func rpc_phase(phase: int, round_number: int) -> void:
if match_state == null:
return
match_state.phase = phase
match_state.round_number = round_number
_announce_phase()
@rpc("authority", "call_remote", "reliable")
func rpc_setup(options: Dictionary, seat: int, roster: Array) -> void:
local_slot = seat
lobby = []
seat_peers.clear()
for entry: Dictionary in roster:
lobby.append(entry.duplicate())
seat_peers[int(entry.slot)] = int(entry.peer)
setup_match(options)
connected = true
lobby_changed.emit()
@rpc("authority", "call_remote", "reliable")
func rpc_state(drafts: Array, building_hp: Dictionary, boss: Dictionary, phase: int, round_number: int) -> void:
if match_state == null:
return
match_state.phase = phase
match_state.round_number = round_number
match_state.building_hp = building_hp
match_state.boss = boss
for row: Dictionary in drafts:
var slot: int = int(row.get("slot", -1))
if slot < 0 or slot >= match_state.players.size():
continue
var player: Dictionary = match_state.players[slot]
player.gold = int(row.gold)
player.ready = bool(row.ready)
player.draft.roster = (row.roster as Array).duplicate(true)
player.draft.hero = (row.hero as Dictionary).duplicate(true)
player.draft.new_bookings = (row.new_bookings as Array).duplicate(true)
player.bookings = (row.bookings as Array).duplicate(true)
for entry: Dictionary in lobby:
for row: Dictionary in drafts:
if int(row.slot) == int(entry.slot):
entry.ready = bool(row.ready)
lobby_changed.emit()
@rpc("authority", "call_remote", "reliable")
func rpc_lobby(entries: Array) -> void:
lobby = []
for entry: Dictionary in entries:
lobby.append(entry.duplicate())
lobby_changed.emit()
@rpc("authority", "call_remote", "unreliable_ordered")
func rpc_units(payload: Array) -> void:
combat_snapshot.emit(payload)
# ---------------------------------------------------------------- host broadcast
func _broadcast_lobby() -> void:
if role == Role.HOST:
rpc("rpc_lobby", lobby)
func _broadcast_state() -> void:
if role != Role.HOST or match_state == null:
return
var drafts: Array = []
for player: Dictionary in match_state.players:
var use_draft: bool = match_state.phase == LuneMatch.Phase.PLANNING
drafts.append({
"slot": player.slot, "gold": player.gold, "ready": player.ready,
"roster": (player.draft.roster if use_draft else player.roster) as Array,
"hero": (player.draft.hero if use_draft else player.hero) as Dictionary,
"bookings": player.bookings as Array,
"new_bookings": player.draft.new_bookings as Array,
})
rpc("rpc_state", drafts, match_state.building_hp, match_state.boss, match_state.phase, match_state.round_number)
func broadcast_combat_units() -> void:
if role != Role.HOST or match_state == null or match_state.combat == null:
return
var payload: Array = []
for unit: LuneUnit in match_state.combat.units:
if not unit.is_mobile():
continue
payload.append({"id": unit.id, "x": unit.position.x, "y": unit.position.y,
"hp": unit.hp, "max": unit.max_hp, "team": unit.team, "alive": unit.alive, "kind": unit.kind})
rpc("rpc_units", payload)
# ---------------------------------------------------------------- lobby / disconnect
func seat_peer(slot: int, peer: int, display_name: String) -> void:
if not is_authority() or slot < 0 or slot > 5:
return
# One peer one seat; rejoining replaces the previous peer of that seat.
var previous: int = seat_of_peer(peer)
if previous >= 0 and previous != slot:
return
for other: int in seat_peers:
if int(seat_peers[other]) == peer and other != slot:
return
var taken: int = seat_of_peer(peer)
if taken >= 0 and taken != slot:
return
if seat_peers.has(slot) and int(seat_peers[slot]) != peer and int(seat_peers[slot]) > 1:
return
seat_peers[slot] = peer
var found: bool = false
for entry: Dictionary in lobby:
if int(entry.slot) == slot:
entry.peer = peer
entry.name = display_name
entry.bot = false
entry.ready = false
found = true
if not found:
lobby.append({"peer": peer, "slot": slot, "name": display_name, "ready": false, "bot": false})
if role == Role.HOST and peer > 1:
rpc_id(peer, "rpc_setup", _public_options(), slot, lobby)
rpc("rpc_lobby", lobby)
lobby_changed.emit()
func _public_options() -> Dictionary:
return {"seed": match_state.seed_value if match_state != null else 1, "human_slot": local_slot,
"bunds": match_state.players.map(func(p: Dictionary) -> String: return str(p.bund)) if match_state != null else []}
func _on_peer_connected(peer: int) -> void:
if role != Role.HOST:
return
# Auto-seat: first free slot that is not peer-held. One peer one seat.
for slot: int in range(6):
if not seat_peers.has(slot):
seat_peer(slot, peer, "Gast %d" % peer)
session_message.emit("Gast auf Slot %d." % slot, false)
return
session_message.emit("Kein freier Sitzplatz – Verbindung wird nicht übernommen.", true)
func _on_peer_disconnected(peer: int) -> void:
var slot: int = seat_of_peer(peer)
if slot < 0:
return
seat_peers.erase(slot)
for entry: Dictionary in lobby:
if int(entry.slot) == slot:
entry.bot = true
entry.peer = 0
entry.ready = true
session_message.emit("%s hat die Verbindung getrennt – Slot wird vom Bot übernommen." % display_name_for(slot), false)
if match_state != null and match_state.phase == LuneMatch.Phase.PLANNING and is_authority():
LuneBot.plan(match_state, slot)
match_state.set_ready(slot)
_broadcast_lobby()
_broadcast_state()
if match_state != null and match_state.phase == LuneMatch.Phase.PLANNING and match_state.all_ready():
host_begin_combat()