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.
389 lines
14 KiB
GDScript
389 lines
14 KiB
GDScript
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()
|