Bound what one packet can cost, and stop the hub eating its own loot

Selecting a character rewrote the entire store to disk, re-placed the
player, re-streamed its map chunks and broadcast a roster to everyone --
off one small reliable packet, at whatever rate a client cared to send.
Repeating a selection you already hold now answers yes and stops, and
select/create are paced per peer. Item moves and experience grants, the
two paths a client can trigger every tick, mark the store dirty and the
server flushes once per tick instead of once per event.

That flush also fixes experience quietly not being saved: grant_xp only
wrote when a level was crossed, and level is DERIVED from lifetime xp, so
the number nobody was keeping was the only number there is. A restart
rolled a character back to its last level-up.

The emergency escape ran in the hub, where completing it means
transferring into the instance you are already in: teleported to spawn,
healed to full, instanced loot dropped, every map chunk re-streamed, once
a second for as long as the key is held. The hub now opts out.

Ground loot evicts the oldest item when the floor fills. That is right
for a dungeon, which closes and takes its litter with it, and wrong for
the hub, which never closes -- a player dropping into a full floor
destroyed whatever someone else had left there longest, and drop is
edge-triggered per tick. A player's drop is refused instead; the world's
own drops still make room.

Also: a linkdead body is not in inst.peers, so closing a CLEARED dungeon
walked past it and left that peer's account and name in the dictionaries
forever -- with the new one-account guard, enough to lock someone out of
their own session. And several encoders wrote mini(size, 255) as a count
and then looped the whole array, which puts records after the count on
the wire; MAX_CLIENTS makes it unreachable, which is why it would have
survived to the day it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5 committed 2026-09-07 17:09:43 +02:00
1 parent ef28970654
commit 39d857c4d1
9 files changed
+253 -13

No files matched your search

+38 -4
View File
@@ -20,6 +20,9 @@ const MAX_ACTIVE := 5
var _accounts: Dictionary[int, Dictionary] = {}
var _path: String = SAVE_PATH
var _rng := RandomNumberGenerator.new()
## Set by [method save_deferred]; cleared by the next write. See that method for
## why one path defers and every other one does not.
var _dirty: bool = false
func _init(path: String = SAVE_PATH) -> void:
@@ -108,12 +111,25 @@ func retire_character(account_id: int, character_id: String) -> void:
## Write a character's carried items back to disk. Separate from grant_xp
## rather than folded into a general "save this character", so the one caller
## reads as what it is.
func set_inventory(account_id: int, character_id: String, items: Array[StringName]) -> void:
##
## [param defer] coalesces the write to the end of the server tick. Item events
## are the one persistence path a client can trigger at the tick rate -- the
## slot byte counts toward the edge, so alternating it with drop held produces
## an action every tick -- and each one otherwise re-serialised every account on
## the server to disk. Deferring bounds that at one write per tick however many
## players are doing it. The risk the immediate write guards against is a crash
## between moving an item and recording it; a tick is 16ms of that, and the
## direction of the loss is an item that stays in your bag, never a duplicate.
func set_inventory(account_id: int, character_id: String, items: Array[StringName],
defer: bool = false) -> void:
var c := get_character(account_id, character_id)
if c == null:
return
c.set_inventory(items)
save()
if defer:
save_deferred()
else:
save()
## Award level-up choices and, if nothing is on the table yet, roll one.
@@ -162,14 +178,32 @@ func grant_xp(account_id: int, character_id: String, amount: int) -> int:
if c == null:
return 0
var gained := c.grant_xp(amount)
if gained > 0:
save()
# Every grant is written, not only the ones that cross a level boundary.
# Saving on gained > 0 alone meant a character could kill its way to one
# point short of a level and lose all of it to a restart, because the level
# is DERIVED from lifetime experience -- the number that was not being kept
# is the only number there is. Deferred so a busy fight still costs one
# write per tick rather than one per kill.
save_deferred()
return gained
# --- Persistence ------------------------------------------------------------
## Mark the store as needing a write, without doing it now. The owner calls
## [method flush] once per tick; see [method set_inventory].
func save_deferred() -> void:
_dirty = true
## Write only if something asked for one since the last write.
func flush() -> void:
if _dirty:
save()
func save() -> void:
_dirty = false
var accounts := {}
for account_id in _accounts:
var entry: Dictionary = _accounts[account_id]