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() 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. func set_inventory(account_id: int, character_id: String, items: Array[StringName]) -> void: var c := get_character(account_id, character_id) if c == null: return c.set_inventory(items) save() ## 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) if gained > 0: save() return gained # --- Persistence ------------------------------------------------------------ func save() -> void: 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