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()