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:
1 parent
ef28970654
commit
39d857c4d1
9 files changed
+253
-13
No files matched your search
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user