class_name ServerRuntime extends Node ## The dedicated server. Owns every instance, ticks them all at the physics ## rate, and is the only place in the codebase allowed to decide what happened. ## ## Clients send intent and nothing else (see [method SimWorld.queue_input]), so ## there is no client message that can move a player, deal damage, cancel a hit ## or shorten an escape channel. Refusing to accept those messages at all is a ## stronger guarantee than validating them after the fact. var instances: Dictionary[int, Instance] = {} var peer_instance: Dictionary[int, int] = {} var peer_names: Dictionary[int, String] = {} ## Authenticated account behind each peer. Set at handshake and never taken ## from anything the client says afterwards. var peer_accounts: Dictionary[int, int] = {} ## Which character each peer is currently playing. var peer_characters: Dictionary[int, String] = {} ## Characters, levels and experience. Owned here: the simulation reads a ## player's level, but only this layer ever writes progression. var store: CharacterStore = null ## Map chunks each peer has been sent, per peer. Reset on every instance ## transfer -- knowledge of one dungeon must not carry into the next. var peer_chunks: Dictionary[int, Dictionary] = {} var lobby: Instance var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID var _snapshot_phase: int = 0 func _ready() -> void: if store == null: store = CharacterStore.new(GameOpts.store_path) if not store.load_from_disk(): # Refusing to start beats starting empty and saving over everyone's # characters on the first level-up. GameLog.error("server", "character store failed to load; refusing to start") get_tree().quit(1) return lobby = Instance.make_lobby(_take_instance_id()) instances[lobby.id] = lobby GameLog.info("server", "lobby instance %d up" % lobby.id) func _take_instance_id() -> int: var id := _next_instance_id _next_instance_id += 1 return id func _physics_process(_delta: float) -> void: _snapshot_phase += 1 var send_snapshot := _snapshot_phase % SimConfig.SNAPSHOT_INTERVAL == 0 var closing: Array[int] = [] for inst in instances.values(): inst.step() _dispatch_events(inst) if send_snapshot and not inst.peers.is_empty(): # Encoded per peer, not once and broadcast: each player is told only # about actors near them. Fog alone was hiding enemies the client # had already been handed, which is no defence against a modified # client at all. for peer in inst.peers: Net.send_snapshot(peer, NetCodec.encode_snapshot( inst.world, inst.exit_countdown_seconds(), peer)) _stream_map(peer, inst) if inst.kind != Protocol.InstanceKind.DUNGEON: continue if inst.state == Instance.State.CLEARED and inst.stage_delay <= 0: closing.append(inst.id) elif inst.is_empty() and not inst.has_linkdead() and inst.age > 60: # has_linkdead() matters: without it a solo player could drop, the # instance would close on the next tick, and their body would # vanish before finishing the escape channel -- exactly the # disconnect cheese the channel exists to prevent. closing.append(inst.id) for id in closing: _close_dungeon(id) ## Split the tick's events into the ones clients need and the ones only the ## server acts on (escape completion, portal use, respawn requests). func _dispatch_events(inst: Instance) -> void: var events := inst.world.drain_events() if events.is_empty(): return # Collected and applied after the send below, because a transfer mutates # inst.peers and would otherwise change the list mid-broadcast. var to_lobby: Array[int] = [] var to_dungeon: Array[int] = [] var died: Array[int] = [] for ev in events: match int(ev["t"]): SimEvent.Type.ENEMY_DIED: _award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", "")))) SimEvent.Type.BOSS_DIED: _award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", "")))) SimEvent.Type.PLAYER_DIED: # Deferred like the transfers below: the payload has not been # sent yet, and a player must still receive news of its own # death before it stops being a member of the instance. died.append(int(ev["peer"])) SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED: var peer := int(ev["peer"]) if not to_lobby.has(peer): to_lobby.append(peer) SimEvent.Type.PORTAL_USED: var peer := int(ev["peer"]) if not to_dungeon.has(peer): to_dungeon.append(peer) _: pass # Bullet spawns are scoped per peer too; everything else (hits, deaths, # phase changes) is low volume and mostly concerns the recipient, so it goes # to everyone. Despawns are deliberately NOT filtered -- a client that was # told about a bullet must always be told it died, or it keeps a phantom. var shared: Array[Dictionary] = [] var spawns: Array[Dictionary] = [] for ev in events: if int(ev["t"]) == SimEvent.Type.BULLET_SPAWN: spawns.append(ev) else: shared.append(ev) for peer in inst.peers: var for_peer := shared if not spawns.is_empty(): for_peer = shared + _spawns_near(inst, peer, spawns) if for_peer.is_empty(): continue Net.send_events(peer, NetCodec.encode_events(inst.world.tick, for_peer)) for peer in died: _on_player_died(inst, peer) for peer in to_lobby: _send_to_lobby(peer) for peer in to_dungeon: _send_to_dungeon(peer) # --- Peer lifecycle --------------------------------------------------------- func on_peer_connected(peer_id: int) -> void: GameLog.info("server", "peer %d connected, awaiting hello" % peer_id) ## A drop inside a dungeon does NOT delete the player. The body is detached ## from the send list but kept in the world, where it channels the same ## one-second escape everyone else does and stays killable the whole time -- ## so quitting the process is never a cheaper exit than pressing the button. ## Only once that channel resolves (_release_linkdead) is the peer forgotten. ## A drop in the hub has nothing to dodge, so it is immediate. func on_peer_disconnected(peer_id: int) -> void: var inst := instance_of(peer_id) if inst == null: _forget_peer(peer_id) return if inst.kind == Protocol.InstanceKind.DUNGEON: inst.detach_peer(peer_id) GameLog.info("server", "peer %d '%s' dropped in instance %d, channelling out" % [peer_id, peer_names.get(peer_id, "?"), inst.id]) _broadcast_roster() return inst.remove_peer(peer_id) _forget_peer(peer_id) GameLog.info("server", "peer %d disconnected" % peer_id) func _forget_peer(peer_id: int) -> void: peer_instance.erase(peer_id) peer_names.erase(peer_id) peer_chunks.erase(peer_id) peer_accounts.erase(peer_id) peer_characters.erase(peer_id) _broadcast_roster() ## The linkdead body finished its channel (or died trying). Either way it leaves ## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are ## the same outcome, so it is simply removed. func _release_linkdead(peer_id: int, inst: Instance) -> void: inst.remove_peer(peer_id) GameLog.info("server", "peer %d released from instance %d after drop" % [peer_id, inst.id]) _forget_peer(peer_id) ## Handshake: validate the ticket into an account, then offer that account's ## characters. A peer is NOT placed in the world here -- it has no character ## yet, and a player without a character has nothing to control. func on_hello(peer_id: int, version: int, ticket: PackedByteArray) -> void: if peer_accounts.has(peer_id): return # a second hello from the same peer is either a bug or an attack if version != Protocol.VERSION: GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, version, Protocol.VERSION]) Net.send_reject(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version]) Net.kick(peer_id) return var account := Net.auth.validate(ticket) if account == AuthProvider.NO_ACCOUNT: GameLog.warn("server", "peer %d failed authentication" % peer_id) Net.send_reject(peer_id, "authentication failed") Net.kick(peer_id) return peer_accounts[peer_id] = account Net.send_welcome(peer_id) GameLog.info("server", "peer %d authenticated as account %d (%s)" % [peer_id, account, Net.auth.provider_name()]) # Auto-select the last character played, so a returning player lands in the # hub rather than at a menu they have already answered. var resume := store.last_played(account) if resume != null: _enter_world_as(peer_id, resume) _send_characters(peer_id) func _send_characters(peer_id: int) -> void: var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) if account == AuthProvider.NO_ACCOUNT: return Net.send_characters(peer_id, NetCodec.encode_characters( store.characters_for(account), peer_characters.get(peer_id, ""))) func on_select_character(peer_id: int, character_id: String) -> void: var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) if account == AuthProvider.NO_ACCOUNT: Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in") return # Looked up against THIS account's characters, so a client cannot select # somebody else's by guessing an id. var c := store.get_character(account, character_id) if c == null: Net.send_select_result(peer_id, Protocol.SelectResult.NO_SUCH_CHARACTER, "no such character") return if not c.active: Net.send_select_result(peer_id, Protocol.SelectResult.CHARACTER_IS_DEAD, "%s is dead" % c.display_name) return _enter_world_as(peer_id, c) Net.send_select_result(peer_id, Protocol.SelectResult.OK, "") _send_characters(peer_id) func on_create_character(peer_id: int, character_name: String) -> void: var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) if account == AuthProvider.NO_ACCOUNT: Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in") return var c := store.create_character(account, character_name) if c == null: Net.send_select_result(peer_id, Protocol.SelectResult.LIMIT_REACHED, "%d living characters is the limit" % CharacterStore.MAX_ACTIVE) return GameLog.info("server", "account %d created '%s'" % [account, c.display_name]) _enter_world_as(peer_id, c) Net.send_select_result(peer_id, Protocol.SelectResult.OK, "") _send_characters(peer_id) ## Put a peer into the hub playing [param c], switching characters if it was ## already in the world. func _enter_world_as(peer_id: int, c: Character) -> void: var account: int = peer_accounts[peer_id] var previous := instance_of(peer_id) if previous != null: previous.remove_peer(peer_id) peer_characters[peer_id] = c.id peer_names[peer_id] = c.display_name store.set_last_played(account, c.id) _place(peer_id, lobby) var p: SimPlayer = lobby.world.players.get(peer_id) if p != null: p.adopt(c) p.reset_for_instance(lobby.world.spawn_point, 0) p.adopt(c) GameLog.info("server", "peer %d playing '%s' (level %d)" % [peer_id, c.display_name, c.level]) _broadcast_roster() func on_input(peer_id: int, data: PackedByteArray) -> void: var inst := instance_of(peer_id) if inst == null: return inst.world.queue_input(peer_id, NetCodec.decode_inputs(data)) func instance_of(peer_id: int) -> Instance: var id: int = peer_instance.get(peer_id, 0) return instances.get(id) # --- Transfers -------------------------------------------------------------- func _place(peer_id: int, inst: Instance) -> void: inst.add_peer(peer_id, peer_names.get(peer_id, "player")) peer_instance[peer_id] = inst.id # Size only: the seed stays server-side, or a client could rebuild the map. peer_chunks[peer_id] = {} Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick, String(inst.boss_id), inst.world.spawn_point, inst.world.map.width, inst.world.map.height, inst.world.portal_pos) # Seed the area around the spawn before anything else, so the player is not # briefly standing in an unrendered void on arrival. _stream_map(peer_id, inst) # A player arriving mid-fight has no idea what is already in the air, so # replay the live bullets as spawn events before the next snapshot lands. var backlog := _live_bullet_events(inst.world, peer_id) if not backlog.is_empty(): Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog)) Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds(), peer_id)) func _transfer(peer_id: int, to: Instance) -> void: var from := instance_of(peer_id) if from != null: from.remove_peer(peer_id) _place(peer_id, to) _broadcast_roster() ## Where everything that ends a dungeon run converges: the escape channel, a ## downed player asking out, and a linkdead body finishing its channel. func _send_to_lobby(peer_id: int) -> void: var from := instance_of(peer_id) if from != null: var p: SimPlayer = from.world.players.get(peer_id) if p != null and p.linkdead: _release_linkdead(peer_id, from) return # The character that just died is retired, so returning "as them" is not an # option. Fall back to whatever is left, and leave the player at the # character screen if nothing is. var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) if account != AuthProvider.NO_ACCOUNT: var current := store.get_character(account, peer_characters.get(peer_id, "")) if current == null or not current.active: var replacement := store.last_played(account) if replacement != null: _enter_world_as(peer_id, replacement) else: if from != null: from.remove_peer(peer_id) peer_instance.erase(peer_id) peer_characters.erase(peer_id) _send_characters(peer_id) return GameLog.info("server", "peer %d escaped to lobby" % peer_id) _transfer(peer_id, lobby) func _send_to_dungeon(peer_id: int) -> void: var target: Instance = null for inst in instances.values(): if inst.accepts_new_party_member(): target = inst break if target == null: target = Instance.make_dungeon(_take_instance_id(), randi(), GameOpts.dungeon_depth) instances[target.id] = target GameLog.info("server", "opened dungeon instance %d" % target.id) _transfer(peer_id, target) func _close_dungeon(id: int) -> void: var inst: Instance = instances.get(id) if inst == null: return for peer in inst.peers.duplicate(): _transfer(peer, lobby) instances.erase(id) GameLog.info("server", "closed dungeon instance %d" % id) ## Send this peer any map chunks near its player that it has not been given yet. ## ## This is the anti-map-hack boundary: a client learns terrain by standing near ## it and never any other way. The radius is generous -- wider than the fog, so ## prediction and bullet simulation always run on known ground -- but it is ## still a small fraction of a dungeon, so the worst a modified client gets is ## a slightly wider view, not the floor plan. func _stream_map(peer_id: int, inst: Instance) -> void: var p: SimPlayer = inst.world.players.get(peer_id) if p == null: return var known: Dictionary = peer_chunks.get(peer_id, {}) var wanted := inst.world.map.chunks_near(p.pos, SimConfig.MAP_STREAM_RADIUS) var batch: Array[int] = [] for id in wanted: if known.has(id): continue known[id] = true batch.append(id) if batch.size() >= SimConfig.MAP_CHUNKS_PER_TICK: break peer_chunks[peer_id] = known if not batch.is_empty(): Net.send_map_chunks(peer_id, inst.id, NetCodec.encode_map_chunks(inst.world.map, batch)) ## Experience is shared by everyone alive in the instance, undivided. Splitting ## it would make bringing a friend cost you progress, which is the opposite of ## what a co-op game wants; the hub roster exists to help people group up. func _award_kill(inst: Instance, amount: int) -> void: if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON: return for peer in inst.peers: var p: SimPlayer = inst.world.players.get(peer) if p == null or not p.alive: continue _grant_xp(peer, amount) func _grant_xp(peer_id: int, amount: int) -> void: var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) var character_id: String = peer_characters.get(peer_id, "") if account == AuthProvider.NO_ACCOUNT or character_id.is_empty(): return var levels := store.grant_xp(account, character_id, amount) if levels <= 0: return # A level raises max health immediately, and heals by the amount gained -- # a level-up mid-fight should feel like relief, not like a bar that grew # further away from full. var c := store.get_character(account, character_id) var inst := instance_of(peer_id) if inst != null: var p: SimPlayer = inst.world.players.get(peer_id) if p != null: var before := p.max_hp p.level = c.level p.max_hp = c.max_hp() p.hp = mini(p.hp + (p.max_hp - before), p.max_hp) GameLog.info("server", "peer %d reached level %d" % [peer_id, c.level]) _send_characters(peer_id) ## Death is permanent. The character is retired -- kept for archival, never ## deleted -- and the player is taken out of the world entirely. ## ## There is deliberately no "return to the hub as the character who just died": ## the run is over, so the peer is unbound and left at the roster screen to pick ## another or make one. A linkdead player is the exception -- it has nobody to ## show a roster to, so its body is left for the escape channel to resolve. func _on_player_died(inst: Instance, peer_id: int) -> void: if inst.kind != Protocol.InstanceKind.DUNGEON: return var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) var character_id: String = peer_characters.get(peer_id, "") if account == AuthProvider.NO_ACCOUNT or character_id.is_empty(): return var c := store.get_character(account, character_id) if c == null or not c.active: return store.retire_character(account, character_id) GameLog.info("server", "peer %d lost '%s' at level %d" % [peer_id, c.display_name, c.level]) var p: SimPlayer = inst.world.players.get(peer_id) if p != null and p.linkdead: _send_characters(peer_id) _broadcast_roster() return inst.remove_peer(peer_id) peer_instance.erase(peer_id) peer_characters.erase(peer_id) peer_chunks.erase(peer_id) _send_characters(peer_id) _broadcast_roster() # --- Roster ----------------------------------------------------------------- ## Tell everyone who is online and where they are, so the hub can show that a ## dungeon is already running before you walk into the portal. ## ## Sent on membership changes only, not per tick -- it is the one message that ## carries names, and nothing about it is time critical. func _broadcast_roster() -> void: var entries: Array[Dictionary] = [] for peer in peer_names: var inst := instance_of(peer) if inst == null: continue var p: SimPlayer = inst.world.players.get(peer) entries.append({ "peer": peer, "name": peer_names[peer], "kind": int(inst.kind), "instance": inst.id, "alive": p != null and p.alive, }) var payload := NetCodec.encode_roster(entries) for inst in instances.values(): for peer in inst.peers: Net.send_roster(peer, payload) ## Bullet spawns within this peer's interest radius. See ## SimConfig.BULLET_INTEREST_RADIUS for why this radius is much wider than the ## one used for actors. func _spawns_near(inst: Instance, peer_id: int, spawns: Array[Dictionary]) -> Array[Dictionary]: var p: SimPlayer = inst.world.players.get(peer_id) if p == null: return spawns var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS var out: Array[Dictionary] = [] for ev in spawns: if p.pos.distance_squared_to(ev["pos"]) <= cull_sq: out.append(ev) return out func _live_bullet_events(world: SimWorld, for_peer: int = 0) -> Array[Dictionary]: var out: Array[Dictionary] = [] var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS for i in world.pool.high_water: if world.pool.alive[i] == 0: continue if observer != null \ and observer.pos.distance_squared_to(world.pool.pos[i]) > cull_sq: continue out.append({ "t": SimEvent.Type.BULLET_SPAWN, "uid": world.pool.uid[i], "pos": world.pool.pos[i], "vel": world.pool.vel[i], "r": world.pool.radius[i], "life": world.pool.life[i], "kind": world.pool.kind[i], "team": world.pool.team[i], "accel": world.pool.accel[i], "turn": world.pool.turn[i], }) return out