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>
866 lines
36 KiB
GDScript
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
|