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 ## Ticks a peer must wait between control messages. Selecting or creating a ## character rewrites the whole store to disk, re-places the player (which wipes ## its map-chunk record and re-streams the neighbourhood), and broadcasts a ## roster to every peer on the server -- all off one small reliable packet. ## Unrated, one client could hold the server in a synchronous JSON write. A ## quarter of a second is far under anything a person clicks deliberately. const CONTROL_COOLDOWN_TICKS := 15 var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID var _snapshot_phase: int = 0 ## Server clock, in physics ticks. Only used to pace the control messages above. var _tick: int = 0 ## Tick each peer was last allowed a control message on. var _peer_control_tick: Dictionary[int, int] = {} ## Peers already told off for going too fast. Without this the refusal logs one ## line per packet, which turns the limiter into the amplification it exists to ## prevent -- a client sending a thousand a second would get a thousand lines ## formatted and written. One line per burst, cleared when the peer behaves. var _peer_control_warned: Dictionary[int, bool] = {} 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: _tick += 1 _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) # One write per tick at most, however many item pickups and experience # grants the tick produced. Everything that defers is bounded here; the # paths that matter more (a character created, retired, or levelled) still # write the moment they happen. store.flush() ## 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] = [] # peer -> which dungeon their portal opens. var to_dungeon: Dictionary[int, StringName] = {} 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", ""))), ev.get("pos", Vector2.ZERO)) SimEvent.Type.BOSS_DIED: _award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))), ev.get("pos", Vector2.ZERO)) SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \ SimEvent.Type.ITEM_DROPPED: # The simulation moved items between the ground and a bag; the # store is what makes that survive a restart. Same division of # labour as experience: the world decides, this layer banks it. _persist_inventory(inst, int(ev["peer"])) 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"]) # Keyed by peer, so a player brushing both entrances in one tick # still only enters one dungeon -- the first portal that # answered. if not to_dungeon.has(peer): to_dungeon[peer] = StringName(ev.get("dungeon", "")) _: 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, 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 _bank_health(inst, peer_id) 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) _peer_control_tick.erase(peer_id) _peer_control_warned.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: # Whatever the channel left it on. Pulling the plug at one hit point must # not be a way to come back whole. _bank_health(inst, peer_id) 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.reject_and_kick(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version]) return var account := Net.auth.validate(ticket) if account == AuthProvider.NO_ACCOUNT: GameLog.warn("server", "peer %d failed authentication" % peer_id) Net.reject_and_kick(peer_id, "authentication failed") return # One account, one session. Two peers on one account each get their own # SimPlayer built from the SAME character record, so both hold a copy of the # same bag and whichever writes last wins -- drop the one potion from both # copies and two potions hit the floor. Refusing the second connection is the # only fix that closes it; reconciling two live mirrors of one record after # the fact cannot be made to work. # # The NEW peer is refused rather than the old one kicked, deliberately: with # an auth provider anyone can spoof, "kick whoever holds this account" would # be a way to disconnect any player on the server at will. var held_by := peer_for_account(account) if held_by != 0: GameLog.warn("server", "peer %d claimed account %d, already held by peer %d" % [peer_id, account, held_by]) Net.reject_and_kick(peer_id, "that account is already playing") 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 # Living characters only. Retirement is bookkeeping for the server's own # archive -- from the player's side a dead character is simply gone, and # listing it would offer a choice that cannot be taken. Net.send_characters(peer_id, NetCodec.encode_characters( store.active_characters(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. # Hub only. A player inside a dungeon who could swap character would have an # instant escape from anything dangerous -- strictly better than the escape # channel, and it would hollow out the whole reason that channel exists. # Checked here rather than hidden in the UI, which a modified client ignores. var here := instance_of(peer_id) if here != null and here.kind != Protocol.InstanceKind.LOBBY: Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB, "you can only change character in the hub") return # Already playing this one, and already where a swap would put you. Answering # yes and stopping is not just an optimisation: the full path rewrites the # store, re-places the player and re-streams its map, so a client that simply # repeats its own selection was free work for the asking. if peer_characters.get(peer_id, "") == character_id and here != null: Net.send_select_result(peer_id, Protocol.SelectResult.OK, "") _send_characters(peer_id) return if not _accept_control(peer_id): return 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 where := instance_of(peer_id) if where != null and where.kind != Protocol.InstanceKind.LOBBY: Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB, "you can only change character in the hub") return if not _accept_control(peer_id): 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: _bank_health(previous, peer_id) 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 adopts, so the old three-line adopt/reset/adopt dance here is gone: # it existed to work around reset_for_instance clobbering the health that # adopt had just computed, and both now derive it the same way. _place(peer_id, lobby) GameLog.info("server", "peer %d playing '%s' (level %d)" % [peer_id, c.display_name, c.level]) _send_upgrades(peer_id) _broadcast_roster() ## Spend one level-up on one of the three options the server put on the table. ## ## Everything about this is checked here rather than trusted: that there is a ## choice owing, that the index names an option the server itself offered, and ## that the player is standing at the NPC. The index is the only thing the ## client contributes, and it selects from a list the client did not write. func on_choose_upgrade(peer_id: int, index: 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(): Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in") return var here := instance_of(peer_id) if here == null or here.kind != Protocol.InstanceKind.LOBBY: Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB, "upgrades are chosen in the hub") return var p: SimPlayer = here.world.players.get(peer_id) if p == null or not here.world.at_upgrade_npc(p.pos): Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AT_THE_NPC, "stand at the quartermaster to spend a level") return var taken := store.take_upgrade(account, character_id, index) if taken.is_empty(): Net.send_select_result(peer_id, Protocol.SelectResult.NOTHING_TO_CHOOSE, "nothing to choose") _send_upgrades(peer_id) return # Rebuild the player's numbers from the new list. adopt() does exactly this # and nothing else that matters here, so it stays the single place where a # character's record becomes a player's stats. var c := store.get_character(account, character_id) if c != null: p.adopt(c) GameLog.info("server", "peer %d took upgrade '%s'" % [peer_id, taken]) Net.send_select_result(peer_id, Protocol.SelectResult.OK, "") _send_upgrades(peer_id) _send_characters(peer_id) func _send_upgrades(peer_id: 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 c := store.get_character(account, character_id) if c == null: return Net.send_upgrades(peer_id, NetCodec.encode_upgrade_state( c.pending_choices, c.offer, c.upgrades)) 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) ## Whether [param peer_id] may spend a control message this tick, recording it ## if so. Silent when it refuses: there is no SelectResult for "too fast", and a ## dropped message at this rate can only come from a client that is not a menu. func _accept_control(peer_id: int) -> bool: var last: int = _peer_control_tick.get(peer_id, -CONTROL_COOLDOWN_TICKS) if _tick - last < CONTROL_COOLDOWN_TICKS: if not _peer_control_warned.get(peer_id, false): _peer_control_warned[peer_id] = true GameLog.warn("server", "peer %d is sending control messages faster than %d ticks apart" % [peer_id, CONTROL_COOLDOWN_TICKS]) return false _peer_control_tick[peer_id] = _tick _peer_control_warned.erase(peer_id) return true ## The peer currently signed in as [param account], or 0 for none. ## ## A peer that dropped inside a dungeon still counts while its linkdead body is ## channelling out, which is the point: reconnecting during that second would ## put the same character in the world twice -- once as the body still being ## shot at, once fresh in the hub -- and a death on the abandoned body would ## then retire the character being played. func peer_for_account(account: int) -> int: if account == AuthProvider.NO_ACCOUNT: return 0 for peer in peer_accounts: if peer_accounts[peer] == account: return peer return 0 # --- 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 # The world hands back a BLANK player -- level 1, base stats, empty bag -- # because SimWorld knows nothing about characters. Giving it back its # character is this layer's job, and for a long time it only happened on # character select: every portal and every escape quietly reset the player # to level 1 with no upgrades and nothing carried. The record on disk was # always correct, which is what made it look like a display bug. _adopt_character(peer_id, inst) # 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, NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id), inst.world.upgrade_npc) # 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)) ## Give the player in [param inst] the character its peer is playing. ## ## The one place a character record becomes a live player, so there is a single ## thing to get right rather than one per transition. func _adopt_character(peer_id: int, inst: Instance) -> 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 # authenticated but still at the roster screen var c := store.get_character(account, character_id) var p: SimPlayer = inst.world.players.get(peer_id) if c == null or p == null: return p.adopt(c) # Health comes off the record, not from a full bar. Arriving used to heal # you outright, which meant a run cost nothing: walk in, take what you can, # walk out, walk back in whole. It is paid off in the hub instead, which # regenerates fast (SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC) but not # instantly. HP_FULL covers a new character and a save written before health # was kept; the clamp covers upgrades that moved the ceiling either way. p.hp = p.max_hp if c.hp == Character.HP_FULL else clampi(c.hp, 1, p.max_hp) func _transfer(peer_id: int, to: Instance) -> void: var from := instance_of(peer_id) if from != null: _bank_health(from, peer_id) 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) ## Put a peer into a forming run of the kind they asked for, opening one if ## there is none. The dungeon id comes from the portal they used, which the ## simulation reported -- never from anything the client said. func _send_to_dungeon(peer_id: int, dungeon_id: StringName = &"") -> void: var wanted := dungeon_id if Dungeons.get_def(dungeon_id) != null \ else Dungeons.default_id() var target: Instance = null for inst in instances.values(): if inst.accepts_new_party_member(wanted): target = inst break if target == null: target = Instance.make_dungeon(_take_instance_id(), randi(), GameOpts.dungeon_depth, wanted) instances[target.id] = target GameLog.info("server", "opened dungeon instance %d (%s)" % [target.id, wanted]) _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) # A linkdead body is not in inst.peers, so the loop above walks straight past # it. The empty-instance path checks has_linkdead() and waits; the CLEARED # path does not, so a party that killed the boss with one member disconnected # left that peer's account, name and instance id in the dictionaries with no # instance to resolve -- invisible, permanent, and enough to keep the account # locked out of its own session by the one-account guard. for p in inst.world.players.values(): if p.linkdead: GameLog.info("server", "peer %d released with instance %d as it closed" % [p.peer_id, id]) _forget_peer(p.peer_id) 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, undivided, by everyone alive and near enough to ## [param at] to have been part of it. ## ## Undivided is the settled decision: splitting it would make bringing a friend ## cost you progress, which is the opposite of what a co-op game wants. The ## radius is a separate question -- who was there -- and it exists because ## without one a player parked at the entrance collected full boss experience ## for the whole run. SimConfig.XP_AWARD_RADIUS is ten percent past what a ## player can see, so anyone who watched it die is inside it and anyone who ## could not possibly have known is not. func _award_kill(inst: Instance, amount: int, at: Vector2) -> void: if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON: return var reach_sq := SimConfig.XP_AWARD_RADIUS * SimConfig.XP_AWARD_RADIUS for peer in inst.peers: var p: SimPlayer = inst.world.players.get(peer) if p == null or not p.alive: continue if p.pos.distance_squared_to(at) > reach_sq: continue _grant_xp(peer, amount) ## Copy a player's bag back onto the character record it belongs to. ## ## Called on every item transaction rather than on a timer, because the whole ## point of persisting an inventory is that a crash between "picked it up" and ## "wrote it down" must not be a way to lose an item -- or, far worse, a way to ## duplicate one. ## Copy a player's current health back onto its character record. ## ## Called wherever the [SimPlayer] is about to stop existing -- every transfer, ## every disconnect -- because SimWorld builds a blank one on the other side and ## whatever was not written down is gone. Also called on item transactions, so ## drinking a potion is banked with the potion that was spent. func _bank_health(inst: Instance, peer_id: 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 p: SimPlayer = inst.world.players.get(peer_id) if p == null or not p.alive: return # a dead character is retired; its health is not a thing anymore store.set_health(account, character_id, p.hp) func _persist_inventory(inst: Instance, peer_id: 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 p: SimPlayer = inst.world.players.get(peer_id) if p == null: return # Deferred: a client can produce an item action every tick, and each one used # to re-serialise every account on the server. ServerRuntime flushes once at # the end of the tick. store.set_inventory(account, character_id, p.inventory, true) # A potion moves an item and hit points in the same action; banking one # without the other would let a restart hand the potion back. if p.alive: store.set_health(account, character_id, p.hp) 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) var earned := store.get_character(account, character_id) if earned == null: return # Mirrored into the world every grant, not only on a level-up, so the bar # tracks each kill. var here := instance_of(peer_id) if here != null: var mine: SimPlayer = here.world.players.get(peer_id) if mine != null: mine.total_xp = earned.total_xp if levels <= 0: return # One choice per level, banked rather than offered immediately: the NPC is # in the hub and the level was earned in a dungeon, so they have to queue or # they would be lost. store.grant_choices(account, character_id, levels) # 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 # Derived rather than copied from the character, so there is one # formula for maximum health and not two that can disagree. p.recompute_max_hp() p.hp = mini(p.hp + (p.max_hp - before), p.max_hp) GameLog.info("server", "peer %d reached level %d (%d choice(s) pending)" % [peer_id, c.level, c.pending_choices]) _send_characters(peer_id) _send_upgrades(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 this peer could actually be reached by. ## ## Tested per bullet rather than against a flat radius, and that distinction is ## the whole point. A spawn event carries the firing actor's exact position, so ## a radius wide enough for the fastest shot in the game hands a modified client ## the location of every enemy inside it -- 2900 units, against an 800-unit ## actor cull and a 900-unit map stream. At the depth the game actually runs at ## the map's own diagonal is about 2521, so the flat radius culled NOTHING: an ## enemy firing anywhere on the floor was pinpointed for everyone, through the ## fog, on terrain the client had deliberately not been streamed. ## ## Asking instead whether THIS bullet can reach ground this player can see keeps ## the guarantee the wide radius existed for -- nothing ever arrives invisible -- ## while a slow, short-lived shot across the map stops being a free radar ping. 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 out: Array[Dictionary] = [] for ev in spawns: if _bullet_concerns(p.pos, ev["pos"], ev["vel"], ev["accel"], ev["life"]): out.append(ev) return out ## Whether a bullet born at [param at] could reach ground visible from ## [param eye] before it expires. BULLET_INTEREST_RADIUS stays on as a hard ## ceiling: test_interest.gd proves no bullet in the game needs more than it, so ## it can only ever agree with the per-bullet answer -- but if some future ## content outruns the computation, the failure is a culled bullet rather than ## an unbounded broadcast. func _bullet_concerns(eye: Vector2, at: Vector2, vel: Vector2, accel: float, life: int) -> bool: var horizon := minf( Movement.bullet_reach(vel.length(), accel, life) + SimConfig.FOG_VIEW_RADIUS, SimConfig.BULLET_INTEREST_RADIUS) return eye.distance_squared_to(at) <= horizon * horizon 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 for i in world.pool.high_water: if world.pool.alive[i] == 0: continue # Same per-bullet question as _spawns_near. The backlog a joiner is sent # is a snapshot of every live bullet, so a flat radius here leaked the # position of every firing enemy in the instance the moment you walked # in -- the one moment you have seen none of the map yet. if observer != null and not _bullet_concerns(observer.pos, world.pool.pos[i], world.pool.vel[i], world.pool.accel[i], world.pool.life[i]): 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