extends GutTest ## Characters and their persistence. These matter more than most tests here: ## the whole point of a character is that it survives, and losing one to a bug ## is not something a player can be compensated for. var store: CharacterStore const ACC := 1234567890123 const OTHER := 9876543210987 func before_each() -> void: # A real file in a throwaway location, so the JSON round trip is exercised # rather than mocked -- serialisation is exactly where this would break. store = CharacterStore.new("user://test_characters_%d.json" % randi()) store.load_from_disk() func after_each() -> void: DirAccess.remove_absolute(ProjectSettings.globalize_path(store._path)) func test_a_new_account_has_nothing() -> void: assert_eq(store.characters_for(ACC).size(), 0) assert_null(store.last_played(ACC)) assert_true(store.can_create(ACC)) func test_creating_and_finding_a_character() -> void: var c := store.create_character(ACC, "Ada") assert_not_null(c) assert_eq(c.display_name, "Ada") assert_eq(c.level, Progression.START_LEVEL) assert_eq(c.max_hp(), SimConfig.PLAYER_MAX_HP) assert_true(c.active) assert_eq(store.get_character(ACC, c.id).id, c.id) func test_accounts_are_isolated() -> void: var mine := store.create_character(ACC, "Mine") store.create_character(OTHER, "Theirs") assert_eq(store.characters_for(ACC).size(), 1) assert_null(store.get_character(OTHER, mine.id), "one account must never see another's characters") func test_the_living_character_limit() -> void: for i in CharacterStore.MAX_ACTIVE: assert_not_null(store.create_character(ACC, "c%d" % i)) assert_false(store.can_create(ACC)) assert_null(store.create_character(ACC, "one too many")) ## The reason the cap counts living characters only: five deaths must not lock ## a player out of their own account permanently. func test_retired_characters_do_not_occupy_a_slot() -> void: var first := store.create_character(ACC, "doomed") for i in CharacterStore.MAX_ACTIVE - 1: store.create_character(ACC, "c%d" % i) assert_false(store.can_create(ACC)) store.retire_character(ACC, first.id) assert_true(store.can_create(ACC), "a death should free the slot it held") assert_eq(store.characters_for(ACC).size(), CharacterStore.MAX_ACTIVE, "but the record itself must still be there") func test_death_retires_rather_than_deletes() -> void: var c := store.create_character(ACC, "Grace") store.retire_character(ACC, c.id) var found := store.get_character(ACC, c.id) assert_not_null(found, "the record is kept for archival") assert_false(found.active) assert_gt(found.died_unix, 0, "and records when it happened") func test_last_played_prefers_the_last_one_and_skips_the_dead() -> void: var a := store.create_character(ACC, "A") var b := store.create_character(ACC, "B") store.set_last_played(ACC, a.id) assert_eq(store.last_played(ACC).id, a.id) store.retire_character(ACC, a.id) assert_eq(store.last_played(ACC).id, b.id, "a dead character must not be auto-selected on login") func test_everything_survives_a_save_and_reload() -> void: var c := store.create_character(ACC, "Persistent") store.grant_xp(ACC, c.id, Progression.xp_to_next(1) + 5) store.set_last_played(ACC, c.id) var dead := store.create_character(ACC, "Departed") store.retire_character(ACC, dead.id) var reloaded := CharacterStore.new(store._path) assert_true(reloaded.load_from_disk()) var got := reloaded.get_character(ACC, c.id) assert_not_null(got, "the character survived the round trip") assert_eq(got.display_name, "Persistent") assert_eq(got.level, 2) assert_eq(got.colour.to_html(false), c.colour.to_html(false)) assert_eq(reloaded.last_played(ACC).id, c.id) assert_false(reloaded.get_character(ACC, dead.id).active, "and so did the fact that one of them died") ## Account ids are 64-bit; JSON numbers are doubles and would round them. They ## are written as decimal strings for exactly this reason. func test_large_account_ids_survive_the_round_trip() -> void: var big := 9007199254740993 store.create_character(big, "Precise") var reloaded := CharacterStore.new(store._path) reloaded.load_from_disk() assert_eq(reloaded.characters_for(big).size(), 1, "a 64-bit account id must not be mangled by JSON") func test_a_corrupt_file_is_refused_rather_than_overwritten() -> void: assert_not_null(store.create_character(ACC, "Valuable")) var f := FileAccess.open(store._path, FileAccess.WRITE) f.store_string("{ this is not json") f.close() var reloaded := CharacterStore.new(store._path) assert_false(reloaded.load_from_disk(), "a bad file must fail loudly; silently starting empty would then " + "save over every character on the next write") func test_granting_experience_reports_levels_gained() -> void: var c := store.create_character(ACC, "Riser") assert_eq(store.grant_xp(ACC, c.id, 1), 0, "not enough for a level") assert_gte(store.grant_xp(ACC, c.id, Progression.total_xp_for_level(3)), 1, "crossing a threshold reports the levels") var after := store.get_character(ACC, c.id) assert_eq(after.level, Progression.level_for_xp(after.total_xp), "level and experience must never disagree") func test_a_dead_character_earns_nothing() -> void: var c := store.create_character(ACC, "Late") store.retire_character(ACC, c.id) store.grant_xp(ACC, c.id, 5000) assert_eq(store.get_character(ACC, c.id).total_xp, 0) ## Names are shown to other players, so they are clamped at the boundary rather ## than trusted anywhere downstream. func test_names_are_sanitised() -> void: assert_eq(Character.sanitize_name(" "), "adventurer", "blank names get a default") assert_lte(Character.sanitize_name("x".repeat(500)).length(), Character.MAX_NAME) var with_control := "ab" + String.chr(7) + String.chr(10) + "cd" assert_eq(Character.sanitize_name(with_control), "abcd", "control characters would let a name break the HUD's layout") ## The suggested name exists so the create field is never blank. It only has to ## be non-empty and survive sanitising -- it is a starting point, not an ## identity. func test_suggested_names_are_usable() -> void: var rng := RandomNumberGenerator.new() rng.seed = 3 for _i in 50: var n := Character.random_name(rng) assert_false(n.is_empty()) assert_lte(n.length(), Character.MAX_NAME) assert_eq(Character.sanitize_name(n), n, "a suggestion must survive the sanitiser unchanged") func test_suggested_names_vary() -> void: var rng := RandomNumberGenerator.new() rng.seed = 9 var seen := {} for _i in 40: seen[Character.random_name(rng)] = true assert_gt(seen.size(), 10, "a fixed suggestion would be worse than none") ## Inventories live on the character, which is what makes them survive a ## restart, a character swap and a walk into a dungeon. func test_the_inventory_survives_a_save_and_reload() -> void: var c := store.create_character(ACC, "Packrat") var carried: Array[StringName] = [Items.HEALTH_POTION, Items.NONE, Items.WARDENS_RATION] store.set_inventory(ACC, c.id, carried) var reloaded := CharacterStore.new(store._path) assert_true(reloaded.load_from_disk()) var got := reloaded.get_character(ACC, c.id) assert_eq(got.inventory.size(), SimConfig.INVENTORY_SLOTS) assert_eq(got.inventory[0], Items.HEALTH_POTION) assert_eq(got.inventory[1], Items.NONE) assert_eq(got.inventory[2], Items.WARDENS_RATION) ## A save file written by a build that had an item this one does not must still ## load -- as an empty slot, never as a different item. func test_an_unknown_item_in_a_save_file_becomes_an_empty_slot() -> void: var restored := Character.from_dict({ "id": "x", "name": "Old", "xp": 0, "inventory": ["health_potion", "phlogiston"], }) assert_eq(restored.inventory[0], Items.HEALTH_POTION) assert_eq(restored.inventory[1], Items.NONE) func test_a_character_from_before_inventories_existed_still_loads() -> void: var restored := Character.from_dict({"id": "y", "name": "Legacy", "xp": 500}) assert_eq(restored.inventory.size(), SimConfig.INVENTORY_SLOTS) assert_eq(restored.display_name, "Legacy") func test_setting_an_inventory_on_a_missing_character_is_harmless() -> void: var nothing: Array[StringName] = [Items.HEALTH_POTION] store.set_inventory(ACC, "no-such-id", nothing) assert_eq(store.characters_for(ACC).size(), 0)