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