saumwacht-godot/scripts/luneraria/net/lune_session.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

699 lines
26 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 and
## show the host's combat through snapshots; they never simulate a combat themselves.
## 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.
## Rejoin: a client that says hello with the name of a departed seat gets that seat back
## while it is still bot-held (no accounts, so the display name is the only key).
## 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 = 4
## Seconds between combat snapshots the host sends (clients interpolate in between).
const FRAME_INTERVAL: float = 0.05
## id, x, y, facing, hp, max_hp, flags, attack_progress, hit_count, attack_serial, ability_serial, hit_flash, shield
const FRAME_STRIDE: int = 13
enum Role { OFFLINE, HOST, CLIENT }
var role: int = Role.OFFLINE
var match_state: LuneMatch
var local_slot: int = -1
var display_name: String = ""
## 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] = []
## display name -> slot of a peer that left; used for rejoining.
var departed: Dictionary = {}
var last_error: String = ""
var connected: bool = false
## Client-side counters (tests and diagnostics).
var frames_received: int = 0
var states_received: int = 0
var _sent_unit_id: int = 0
var _frame_clock: float = 0.0
var _frame_serial: int = 0
var _last_frame_serial: int = -1
var _last_frame_msec: int = 0
var _mirror: Dictionary = {}
## slot -> round a bot already planned; a bot seat plans exactly once per planning phase.
var _bot_planned: Dictionary = {}
signal lobby_changed
signal match_resetup
signal session_message(text: String, is_error: bool)
signal phase_announced(phase: int, round_number: int)
signal state_received
signal host_lost
func is_authority() -> bool:
return role != Role.CLIENT
func is_networked() -> bool:
return role != Role.OFFLINE
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)
_mirror.clear()
_bot_planned.clear()
if is_authority():
_apply_seats()
match_resetup.emit()
_announce_phase()
## Seated peers are humans: they must ready up themselves and carry their lobby name.
func _apply_seats() -> void:
for player: Dictionary in match_state.players:
var slot: int = int(player.slot)
var human: bool = seat_peers.has(slot)
player.is_human = human
if match_state.phase == LuneMatch.Phase.PLANNING:
player.ready = not human
if human and int(seat_peers[slot]) != 1:
player.name = _lobby_name(slot, str(player.name))
func _lobby_name(slot: int, fallback: String) -> String:
for entry: Dictionary in lobby:
if int(entry.slot) == slot and not bool(entry.bot):
return str(entry.name)
return fallback
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, host_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
display_name = host_name
var human: int = int(options.get("human_slot", 0))
local_slot = human
seat_peers = {human: 1}
departed.clear()
lobby = [{"peer": 1, "slot": human, "name": host_name, "ready": false, "bot": false}]
for slot: int in range(6):
if slot != human:
lobby.append({"peer": 0, "slot": slot, "name": "Bot", "ready": true, "bot": true})
options["human_slot"] = human
setup_match(options)
lobby_changed.emit()
return OK
func join_enet(address: String, port: int, guest_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
display_name = guest_name
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)
rpc_id(1, "rpc_hello", PROTOCOL, display_name)
func _on_connection_failed() -> void:
last_error = "Verbindung zum Host fehlgeschlagen."
connected = false
session_message.emit(last_error, true)
host_lost.emit()
func _on_server_lost() -> void:
last_error = "Host hat die Verbindung getrennt."
connected = false
session_message.emit(last_error, true)
host_lost.emit()
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)
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()
departed.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.", "pending": true}
return host_command(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.
## A seat that reported ready has committed its plan; later commands are refused.
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:
return _answer(peer, slot, {"ok": false, "message": "Fremder Sitz oder ungültige Einheiten-ID."})
if match_state.phase != LuneMatch.Phase.PLANNING:
return _answer(peer, slot, {"ok": false, "message": "Veralteter Befehl (Planung vorbei)."})
if bool(match_state.players[slot].ready):
return _answer(peer, slot, {"ok": false, "message": "Bereits bereit gemeldet – die Planung ist verbindlich."})
var result: Dictionary
if str(command.get("type", "")) == "undo":
result = match_state.undo(slot)
else:
result = match_state.submit(slot, command)
_answer(peer, slot, result)
_broadcast_state()
return result
func _answer(peer: int, slot: int, result: Dictionary) -> Dictionary:
if role == Role.HOST and peer > 1:
rpc_id(peer, "rpc_feedback", slot, result)
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()
else:
_broadcast_state()
func plan_bot_seats() -> void:
if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
return
for slot: int in range(6):
if not seat_peers.has(slot):
_plan_bot(slot)
func _plan_bot(slot: int) -> void:
if int(_bot_planned.get(slot, -1)) == match_state.round_number:
match_state.set_ready(slot)
return
_bot_planned[slot] = match_state.round_number
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()
_sent_unit_id = 0
_frame_clock = 0.0
_announce_phase()
if role == Role.HOST:
_broadcast_state()
rpc("rpc_combat_begin", match_state.round_number, _new_unit_records(), _building_hp_array())
## Host: call once per rendered frame while combat runs; sends throttled snapshots.
func host_tick(delta: float) -> void:
if role != Role.HOST or match_state == null or match_state.phase != LuneMatch.Phase.COMBAT:
return
_frame_clock += delta
if _frame_clock < FRAME_INTERVAL:
return
_frame_clock = 0.0
var spawned: Array = _new_unit_records()
if not spawned.is_empty():
rpc("rpc_units_spawned", spawned)
_frame_serial += 1
rpc("rpc_frame", _frame_serial, match_state.combat.elapsed, _unit_frame(), _building_hp_array())
## Host: call after the settlement (phase left COMBAT) so clients follow in the same step.
func host_after_combat() -> void:
if not is_authority() or match_state == null:
return
_announce_phase()
_broadcast_state()
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()
host_after_combat()
## Host rematch: a new match with the same seats; clients relaunch on rpc_setup.
func host_rematch(options: Dictionary) -> void:
if not is_authority():
return
options["human_slot"] = local_slot
for entry: Dictionary in lobby:
entry.ready = bool(entry.bot)
setup_match(options)
if role == Role.HOST:
for slot: int in seat_peers:
var peer: int = int(seat_peers[slot])
if peer > 1:
_send_full_sync(peer, slot)
_broadcast_lobby()
# ---------------------------------------------------------------- rpc (client -> host)
@rpc("any_peer", "call_remote", "reliable")
func rpc_hello(protocol: int, guest_name: String) -> void:
if role != Role.HOST:
return
host_hello(multiplayer.get_remote_sender_id(), protocol, guest_name)
## Testable host entry for a new connection: protocol check, rejoin or first free seat.
func host_hello(peer: int, protocol: int, guest_name: String) -> int:
if protocol != PROTOCOL:
_refuse(peer, "Protokoll %d passt nicht zum Host (%d). Bitte dieselbe Version nutzen." % [protocol, PROTOCOL])
return -1
if seat_of_peer(peer) >= 0:
return seat_of_peer(peer)
var clean: String = guest_name.strip_edges().left(24)
if clean.is_empty():
clean = "Gast %d" % peer
var slot: int = -1
var rejoin: bool = departed.has(clean) and not seat_peers.has(int(departed[clean]))
if rejoin:
slot = int(departed[clean])
else:
# New guests prefer seats nobody may return to; a departed seat is the last resort.
var free: Array = range(6).filter(func(c: int) -> bool: return not seat_peers.has(c))
var open: Array = free.filter(func(c: int) -> bool: return not departed.values().has(c))
slot = open[0] if not open.is_empty() else (free[0] if not free.is_empty() else -1)
if slot < 0:
_refuse(peer, "Kein freier Sitzplatz.")
session_message.emit("Kein freier Sitzplatz für %s." % clean, true)
return -1
departed.erase(clean)
seat_peer(slot, peer, clean)
session_message.emit("%s %s Slot %d." % [clean, "kehrt zurück auf" if rejoin else "sitzt auf", slot], false)
return slot
func _refuse(peer: int, reason: String) -> void:
if role == Role.HOST:
rpc_id(peer, "rpc_refused", reason)
@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 (host -> client)
@rpc("authority", "call_remote", "reliable")
func rpc_refused(reason: String) -> void:
last_error = reason
session_message.emit(reason, true)
host_lost.emit()
@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_setup(options: Dictionary, seat: int, roster: Array) -> void:
local_slot = seat
lobby = []
seat_peers.clear()
for entry: Dictionary in roster:
lobby.append(entry.duplicate())
if not bool(entry.get("bot", false)):
seat_peers[int(entry.slot)] = int(entry.peer)
options["human_slot"] = seat
setup_match(options)
connected = true
lobby_changed.emit()
## Client: applies the host's public state. The combat phase itself only starts with
## rpc_combat_begin, so a state message can never bind views to a stale combat.
@rpc("authority", "call_remote", "reliable")
func rpc_state(state: Dictionary) -> void:
if match_state == null:
return
var previous_phase: int = match_state.phase
match_state.round_number = int(state.round)
match_state.winner = int(state.winner)
match_state.end_note = str(state.end_note)
match_state.building_hp = (state.building_hp as Dictionary).duplicate()
match_state.boss = (state.boss as Dictionary).duplicate(true)
match_state.last_settlement = (state.last_settlement as Dictionary).duplicate(true)
for row: Dictionary in state.players:
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.name = "Du" if slot == local_slot else str(row.name)
player.is_human = bool(row.is_human)
player.gold = int(row.gold)
player.ready = bool(row.ready)
player.earned = int(row.earned)
player.spent = int(row.spent)
player.next_seq = int(row.next_seq)
player.roster = (row.roster as Array).duplicate(true)
player.hero = (row.hero as Dictionary).duplicate(true)
player.bookings = (row.bookings as Array).duplicate(true)
# Only the own seat receives its private draft; the others show committed state.
player.draft = (row.draft as Dictionary).duplicate(true) if row.has("draft") else match_state._snapshot(player)
var undo: Array = []
undo.resize(int(row.get("undo", 0)))
player.undo = undo
var phase: int = int(state.phase)
if phase != LuneMatch.Phase.COMBAT:
if match_state.combat != null:
match_state.combat.destroyed_cores.assign(state.destroyed_cores)
match_state.phase = phase
for entry: Dictionary in lobby:
entry.ready = bool(match_state.players[int(entry.slot)].ready)
states_received += 1
if previous_phase != match_state.phase:
_announce_phase()
state_received.emit()
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()
## Client: builds a display mirror of the host's combat. Rules never run on it.
@rpc("authority", "call_remote", "reliable")
func rpc_combat_begin(round_number: int, records: Array, building_hp: PackedFloat32Array) -> void:
if match_state == null:
return
var mirror := LuneCombat.new(match_state.map, match_state.balance, round_number)
for index: int in range(match_state.map.buildings.size()):
var hp: float = building_hp[index] if index < building_hp.size() else 0.0
if hp > 0.0:
mirror.add_building(match_state.map.buildings[index], hp)
_mirror.clear()
match_state.combat = mirror
match_state.round_number = round_number
_add_mirror_units(records)
_last_frame_msec = Time.get_ticks_msec()
match_state.phase = LuneMatch.Phase.COMBAT
_announce_phase()
@rpc("authority", "call_remote", "reliable")
func rpc_units_spawned(records: Array) -> void:
if match_state != null and match_state.combat != null and match_state.phase == LuneMatch.Phase.COMBAT:
_add_mirror_units(records)
## Frame serials rise across rounds, so a late frame of the previous combat is ignored.
@rpc("authority", "call_remote", "unreliable_ordered")
func rpc_frame(serial: int, elapsed: float, data: PackedFloat32Array, building_hp: PackedFloat32Array) -> void:
if match_state == null or match_state.combat == null or match_state.phase != LuneMatch.Phase.COMBAT or serial <= _last_frame_serial:
return
_last_frame_serial = serial
_last_frame_msec = Time.get_ticks_msec()
frames_received += 1
var combat: LuneCombat = match_state.combat
combat.elapsed = elapsed
for base: int in range(0, data.size() - FRAME_STRIDE + 1, FRAME_STRIDE):
var unit: LuneUnit = _mirror.get(int(data[base]))
if unit == null:
continue
unit.previous_position = unit.position
unit.position = Vector2(data[base + 1], data[base + 2])
unit.facing = data[base + 3]
unit.hp = data[base + 4]
unit.max_hp = data[base + 5]
var flags: int = int(data[base + 6])
if unit.alive and flags & 1 == 0:
unit.death_time = elapsed
unit.alive = flags & 1 != 0
unit.in_range = flags & 2 != 0
unit.attack_progress = data[base + 7]
unit.hit_count = int(data[base + 8])
unit.attack_serial = int(data[base + 9])
unit.ability_serial = int(data[base + 10])
unit.hit_flash = data[base + 11]
unit.shield = data[base + 12]
for index: int in range(mini(building_hp.size(), match_state.map.buildings.size())):
var id: String = str(match_state.map.buildings[index].id)
if combat.buildings.has(id):
var building: LuneUnit = combat.buildings[id]
building.hp = building_hp[index]
building.alive = building.hp > 0.0
## Client interpolation factor between the last two snapshots.
func frame_alpha() -> float:
return clampf(float(Time.get_ticks_msec() - _last_frame_msec) / (FRAME_INTERVAL * 1000.0), 0.0, 1.0)
func _add_mirror_units(records: Array) -> void:
for record: Dictionary in records:
var id: int = int(record.id)
if _mirror.has(id):
continue
var unit := LuneUnit.new()
unit.id = id
unit.kind = str(record.kind)
unit.team = int(record.team)
unit.team_index = int(record.team_index)
unit.owner_slot = int(record.owner_slot)
unit.persistent_uid = str(record.uid)
unit.lane = str(record.lane)
unit.zone = str(record.zone)
unit.def = record.def
unit.position = Vector2(float(record.x), float(record.y))
unit.previous_position = unit.position
unit.facing = float(record.facing)
unit.hp = float(record.hp)
unit.max_hp = float(record.max_hp)
unit.alive = bool(record.alive)
match_state.combat.units.append(unit)
_mirror[id] = unit
# ---------------------------------------------------------------- host serialisation
func _new_unit_records() -> Array:
var records: Array = []
var highest: int = _sent_unit_id
for unit: LuneUnit in match_state.combat.units:
if not unit.is_mobile() or unit.id <= _sent_unit_id:
continue
highest = maxi(highest, unit.id)
records.append(_unit_record(unit))
_sent_unit_id = highest
return records
func _all_unit_records() -> Array:
var records: Array = []
for unit: LuneUnit in match_state.combat.units:
if unit.is_mobile():
records.append(_unit_record(unit))
return records
func _unit_record(unit: LuneUnit) -> Dictionary:
return {"id": unit.id, "kind": unit.kind, "team": unit.team, "team_index": unit.team_index, "owner_slot": unit.owner_slot,
"uid": unit.persistent_uid, "lane": unit.lane, "zone": unit.zone, "def": _plain(unit.def),
"x": unit.position.x, "y": unit.position.y, "facing": unit.facing, "hp": unit.hp, "max_hp": unit.max_hp, "alive": unit.alive}
## Keeps only plain data so a definition can cross the wire (objects are never sent).
static func _plain(value: Variant) -> Variant:
match typeof(value):
TYPE_DICTIONARY:
var result: Dictionary = {}
for key: Variant in value:
if typeof(value[key]) != TYPE_OBJECT:
result[key] = _plain(value[key])
return result
TYPE_ARRAY:
var items: Array = []
for item: Variant in value:
if typeof(item) != TYPE_OBJECT:
items.append(_plain(item))
return items
return value
func _unit_frame() -> PackedFloat32Array:
var data := PackedFloat32Array()
for unit: LuneUnit in match_state.combat.units:
if not unit.is_mobile():
continue
data.append_array([unit.id, unit.position.x, unit.position.y, unit.facing, unit.hp, unit.max_hp,
(1 if unit.alive else 0) | (2 if unit.in_range else 0), unit.attack_progress, unit.hit_count,
unit.attack_serial, unit.ability_serial, unit.hit_flash, unit.shield])
return data
func _building_hp_array() -> PackedFloat32Array:
var data := PackedFloat32Array()
for definition: Dictionary in match_state.map.buildings:
var hp: float = float(match_state.building_hp.get(definition.id, 0.0))
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null and match_state.combat.buildings.has(definition.id):
var building: LuneUnit = match_state.combat.buildings[definition.id]
hp = building.hp if building.alive else 0.0
data.append(hp)
return data
## Public state for one seat: every committed value, plus the private draft of that seat only.
func state_for(slot: int) -> Dictionary:
var rows: Array = []
for player: Dictionary in match_state.players:
var seat: int = int(player.slot)
var row := {"slot": seat, "name": _lobby_name(seat, str(player.name)) if seat_peers.has(seat) else str(player.name),
"is_human": player.is_human, "gold": player.gold, "ready": player.ready, "earned": player.earned, "spent": player.spent,
"next_seq": player.next_seq, "roster": player.roster, "hero": player.hero, "bookings": player.bookings}
if seat == slot:
row.draft = (player.draft as Dictionary).duplicate(true)
row.undo = (player.undo as Array).size()
rows.append(row)
return {"phase": match_state.phase, "round": match_state.round_number, "winner": match_state.winner, "end_note": match_state.end_note,
"building_hp": match_state.building_hp, "boss": match_state.boss, "last_settlement": _plain(match_state.last_settlement),
"destroyed_cores": (match_state.combat.destroyed_cores if match_state.combat != null else []) as Array, "players": rows}
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
for slot: int in seat_peers:
var peer: int = int(seat_peers[slot])
if peer > 1:
rpc_id(peer, "rpc_state", state_for(slot))
func _send_full_sync(peer: int, slot: int) -> void:
rpc_id(peer, "rpc_setup", _public_options(), slot, lobby)
rpc_id(peer, "rpc_state", state_for(slot))
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null:
rpc_id(peer, "rpc_combat_begin", match_state.round_number, _all_unit_records(), _building_hp_array())
# ---------------------------------------------------------------- lobby / disconnect
func seat_peer(slot: int, peer: int, seat_name: String) -> void:
if not is_authority() or slot < 0 or slot > 5:
return
# One peer one seat; a seat held by another peer is never taken over.
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:
return
seat_peers[slot] = peer
var found: bool = false
for entry: Dictionary in lobby:
if int(entry.slot) == slot:
entry.peer = peer
entry.name = seat_name
entry.bot = false
entry.ready = false
found = true
if not found:
lobby.append({"peer": peer, "slot": slot, "name": seat_name, "ready": false, "bot": false})
if match_state != null:
var player: Dictionary = match_state.players[slot]
player.is_human = true
player.name = seat_name
# A seat taken during planning drops the bot's uncommitted draft: the gold is the human's.
if match_state.phase == LuneMatch.Phase.PLANNING:
player.draft = match_state._snapshot(player)
player.undo = []
player.ready = false
if role == Role.HOST and peer > 1:
_send_full_sync(peer, slot)
_broadcast_lobby()
_broadcast_state()
lobby_changed.emit()
func _public_options() -> Dictionary:
return {"seed": match_state.seed_value if match_state != null else 1,
"bunds": match_state.players.map(func(p: Dictionary) -> String: return str(p.bund)) if match_state != null else []}
func _on_peer_disconnected(peer: int) -> void:
if not is_authority():
return
var slot: int = seat_of_peer(peer)
if slot < 0:
return
seat_peers.erase(slot)
var gone_name: String = display_name_for(slot)
departed[gone_name] = 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." % gone_name, false)
if match_state != null:
match_state.players[slot].is_human = false
if match_state.phase == LuneMatch.Phase.PLANNING and is_authority():
_plan_bot(slot)
_broadcast_lobby()
if match_state != null and match_state.phase == LuneMatch.Phase.PLANNING and match_state.all_ready():
host_begin_combat()
else:
_broadcast_state()