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>
272 lines
9.5 KiB
GDScript
272 lines
9.5 KiB
GDScript
class_name CharacterStore
|
|
extends RefCounted
|
|
## Server-side persistence for accounts and their characters.
|
|
##
|
|
## JSON on disk, because the shape is small, the write rate is low (a level-up
|
|
## or a death, not a tick), and a save file you can open in a text editor is
|
|
## worth a great deal while a game is still being built. If this ever becomes a
|
|
## bottleneck the interface is narrow enough to put a database behind.
|
|
##
|
|
## Keyed by account id -- a 64-bit integer, deliberately the same shape as a
|
|
## SteamID64 so swapping [AuthProvider] for a real Steam one needs no migration.
|
|
|
|
const SAVE_PATH := "user://characters.json"
|
|
const FORMAT_VERSION := 1
|
|
## Live characters an account may hold at once. Retired ones do not count: a
|
|
## player who has died five times must not be locked out of their own account.
|
|
const MAX_ACTIVE := 5
|
|
|
|
## account_id -> { "characters": Array[Character], "last_played": String }
|
|
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:
|
|
_path = path
|
|
_rng.randomize()
|
|
|
|
|
|
# --- Queries ----------------------------------------------------------------
|
|
|
|
func characters_for(account_id: int) -> Array[Character]:
|
|
var entry: Dictionary = _accounts.get(account_id, {})
|
|
var out: Array[Character] = []
|
|
for c in entry.get("characters", []):
|
|
out.append(c)
|
|
return out
|
|
|
|
|
|
func active_characters(account_id: int) -> Array[Character]:
|
|
var out: Array[Character] = []
|
|
for c in characters_for(account_id):
|
|
if c.active:
|
|
out.append(c)
|
|
return out
|
|
|
|
|
|
func get_character(account_id: int, character_id: String) -> Character:
|
|
for c in characters_for(account_id):
|
|
if c.id == character_id:
|
|
return c
|
|
return null
|
|
|
|
|
|
## The character to select on login: the last one played if it is still alive,
|
|
## otherwise the newest living one, otherwise nothing.
|
|
func last_played(account_id: int) -> Character:
|
|
var entry: Dictionary = _accounts.get(account_id, {})
|
|
var wanted := String(entry.get("last_played", ""))
|
|
var c := get_character(account_id, wanted)
|
|
if c != null and c.active:
|
|
return c
|
|
var living := active_characters(account_id)
|
|
if living.is_empty():
|
|
return null
|
|
var newest: Character = living[0]
|
|
for candidate in living:
|
|
if candidate.created_unix > newest.created_unix:
|
|
newest = candidate
|
|
return newest
|
|
|
|
|
|
func can_create(account_id: int) -> bool:
|
|
return active_characters(account_id).size() < MAX_ACTIVE
|
|
|
|
|
|
# --- Mutations --------------------------------------------------------------
|
|
|
|
## Returns null when the account is already at its living-character limit.
|
|
func create_character(account_id: int, character_name: String) -> Character:
|
|
if not can_create(account_id):
|
|
return null
|
|
var c := Character.create(character_name, _rng)
|
|
var entry: Dictionary = _accounts.get(account_id, {"characters": [], "last_played": ""})
|
|
entry["characters"].append(c)
|
|
entry["last_played"] = c.id
|
|
_accounts[account_id] = entry
|
|
save()
|
|
return c
|
|
|
|
|
|
func set_last_played(account_id: int, character_id: String) -> void:
|
|
var entry: Dictionary = _accounts.get(account_id, {"characters": [], "last_played": ""})
|
|
entry["last_played"] = character_id
|
|
_accounts[account_id] = entry
|
|
save()
|
|
|
|
|
|
## Death. The record stays; only its active flag changes.
|
|
func retire_character(account_id: int, character_id: String) -> void:
|
|
var c := get_character(account_id, character_id)
|
|
if c == null or not c.active:
|
|
return
|
|
c.retire()
|
|
save()
|
|
|
|
|
|
## 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.
|
|
##
|
|
## [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)
|
|
if defer:
|
|
save_deferred()
|
|
else:
|
|
save()
|
|
|
|
|
|
## Bank a character's current health. Deferred like the inventory: it changes
|
|
## every tick a player is regenerating, and the server flushes once a tick.
|
|
func set_health(account_id: int, character_id: String, hp: int) -> void:
|
|
var c := get_character(account_id, character_id)
|
|
if c == null:
|
|
return
|
|
c.hp = maxi(hp, 1)
|
|
save_deferred()
|
|
|
|
|
|
## Award level-up choices and, if nothing is on the table yet, roll one.
|
|
##
|
|
## The offer is rolled HERE and stored, not generated on demand when the screen
|
|
## opens: an offer that regenerated per request would be a free reroll, and a
|
|
## player would simply close and reopen until a legendary appeared.
|
|
func grant_choices(account_id: int, character_id: String, count: int) -> void:
|
|
var c := get_character(account_id, character_id)
|
|
if c == null or count <= 0:
|
|
return
|
|
c.pending_choices += count
|
|
_refresh_offer(c)
|
|
save()
|
|
|
|
|
|
## Spend one choice on [param index] of the character's current offer. Returns
|
|
## the upgrade taken, or an empty id if the choice was not available -- the
|
|
## caller is the server, and "not available" is a refusal, not an error.
|
|
func take_upgrade(account_id: int, character_id: String, index: int) -> StringName:
|
|
var c := get_character(account_id, character_id)
|
|
if c == null or not c.active or c.pending_choices <= 0:
|
|
return &""
|
|
if index < 0 or index >= c.offer.size():
|
|
return &""
|
|
var chosen := c.offer[index]
|
|
c.upgrades.append(chosen)
|
|
c.pending_choices -= 1
|
|
# Cleared before re-rolling, so the next choice is a fresh three rather than
|
|
# the two that were passed over.
|
|
c.offer.clear()
|
|
_refresh_offer(c)
|
|
save()
|
|
return chosen
|
|
|
|
|
|
func _refresh_offer(c: Character) -> void:
|
|
if c.pending_choices > 0 and c.offer.is_empty():
|
|
c.offer = Upgrades.roll_offer(_rng, SimConfig.UPGRADE_CHOICES)
|
|
elif c.pending_choices <= 0:
|
|
c.offer.clear()
|
|
|
|
|
|
func grant_xp(account_id: int, character_id: String, amount: int) -> int:
|
|
var c := get_character(account_id, character_id)
|
|
if c == null:
|
|
return 0
|
|
var gained := c.grant_xp(amount)
|
|
# 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]
|
|
var chars := []
|
|
for c in entry["characters"]:
|
|
chars.append(c.to_dict())
|
|
# JSON object keys are strings; account ids are 64-bit and would lose
|
|
# precision as JSON numbers, so they are written as decimal strings.
|
|
accounts[str(account_id)] = {
|
|
"characters": chars,
|
|
"last_played": entry.get("last_played", ""),
|
|
}
|
|
var f := FileAccess.open(_path, FileAccess.WRITE)
|
|
if f == null:
|
|
GameLog.error("store", "cannot write %s (error %d)" % [_path, FileAccess.get_open_error()])
|
|
return
|
|
f.store_string(JSON.stringify({"version": FORMAT_VERSION, "accounts": accounts}, "\t"))
|
|
f.close()
|
|
|
|
|
|
## A missing file is a new server, not an error. A corrupt one is refused
|
|
## loudly and left alone rather than silently overwritten -- losing every
|
|
## character to a stray byte would be far worse than refusing to start.
|
|
func load_from_disk() -> bool:
|
|
_accounts.clear()
|
|
if not FileAccess.file_exists(_path):
|
|
return true
|
|
var f := FileAccess.open(_path, FileAccess.READ)
|
|
if f == null:
|
|
GameLog.error("store", "cannot read %s" % _path)
|
|
return false
|
|
var text := f.get_as_text()
|
|
f.close()
|
|
# JSON.new().parse() rather than JSON.parse_string(): it reports where the
|
|
# file is malformed instead of just returning null, which is the difference
|
|
# between a usable error and a mystery when someone's save will not load.
|
|
var json := JSON.new()
|
|
if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY:
|
|
GameLog.error("store", "%s is not valid JSON (line %d: %s); refusing to overwrite it"
|
|
% [_path, json.get_error_line(), json.get_error_message()])
|
|
return false
|
|
var data: Dictionary = json.data
|
|
var accounts: Dictionary = data.get("accounts", {})
|
|
for key in accounts:
|
|
var account_id := int(str(key))
|
|
var entry: Dictionary = accounts[key]
|
|
var chars: Array[Character] = []
|
|
for raw in entry.get("characters", []):
|
|
chars.append(Character.from_dict(raw))
|
|
_accounts[account_id] = {
|
|
"characters": chars,
|
|
"last_played": String(entry.get("last_played", "")),
|
|
}
|
|
GameLog.info("store", "loaded %d account(s) from %s" % [_accounts.size(), _path])
|
|
return true
|