Files
transcience/src/net/server_runtime.gd
T
claudeandClaude Opus 5 4aeb6d229d Health survives a door, and experience needs you to be there
Arriving anywhere set health to maximum, so a run cost nothing: walk in,
take what you could, walk out, walk back in whole. Current health now
lives on the character record for the same reason the inventory does --
SimWorld builds a blank player on every instance change, so anything that
must survive a door has to be written down -- and it is banked at every
transfer, every disconnect, and on every item transaction, since a potion
moves an item and hit points in one action.

The hub is where that damage is paid off, at 10% of maximum per second:
about ten seconds from nothing, fast enough not to be a chore and slow
enough to be a decision before walking back in. The dungeon rate is
unchanged, so waiting one out is still not a plan. Regeneration is a
property of the world now rather than a constant.

adopt() no longer touches current health. It is called mid-session by
on_choose_upgrade, and reading health from the record there would snap a
player back to whatever was last written to disk; recompute_max_hp still
clamps downwards so Glass Cannon takes the hit points above the new
ceiling with it. ServerRuntime._adopt_character stays the one place a
record becomes a live player.

Experience is still undivided, but now only among players within
XP_AWARD_RADIUS of the kill -- ten percent past what a player can see, so
anyone who watched it die is inside and someone parked in the entrance
room is not. The death events carry a position for this, and deliberately
do not put it on the wire: encode_events still writes the id alone.

diag_progression covers the whole chain end to end -- wounded in a
dungeon, escaped to the hub with the wound intact, healing there, and
walking back in still hurt -- because a unit test cannot transfer a peer.
Both halves were mutation-tested against it; the unit suite alone missed
the arrival heal entirely.

The Proving Grounds is recorded in the roadmap as a debugging tool that
does not ship: it pays full experience for a boss with 8% health.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:48:20 +02:00

866 lines
36 KiB
GDScript

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