Files
transcience/tests/unit/test_character_store.gd
T
claude b351bc2d55
ci / verify (push) Successful in 48s
Stage 4: upgrades, and a quartermaster to spend them at
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.

The four ambiguities in the brief were settled with the user first, since
each changes what gets written:

  damage      base x (1 + sum additive) x product multiplicative. The flat
              +5% every upgrade carries, spread's -10%, doubleshot's -50%
              and glass cannon's +100% pool; sniper multiplies on top, so
              two snipers is 4x and not +200%.
  glass       half the LEVELLED maximum, multiplying if taken twice, so the
              price does not fade to a rounding error by level 15.
  poison      independent stacks, not a refresh.
  split       +/-45 degrees from the original heading.

Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.

Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.

Two invariants this collided with, both now pinned:

  - bullet speed gained a ceiling. Wall collision samples once per tick, so
    anything over a tile per tick tunnels; two snipers asked for 2480 u/s
    against a 1920 threshold, and a tunnelling bullet looks like a bullet.
  - BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
    now the longest-travelling bullet in the game. test_interest measured
    the worst case from static content, which upgrades quietly invalidated.

Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.

tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.

Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.

check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:34:58 +02:00

315 lines
12 KiB
GDScript

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)
# --- Upgrades ---------------------------------------------------------------
func test_a_level_banks_a_choice_and_puts_three_on_the_table() -> void:
var c := store.create_character(ACC, "Riser")
store.grant_choices(ACC, c.id, 1)
assert_eq(c.pending_choices, 1)
assert_eq(c.offer.size(), SimConfig.UPGRADE_CHOICES)
## The offer is rolled once and held. If it regenerated per request, closing and
## reopening the screen would be a free reroll until a legendary appeared.
func test_the_offer_is_stable_until_something_is_taken() -> void:
var c := store.create_character(ACC, "Picky")
store.grant_choices(ACC, c.id, 1)
var first := c.offer.duplicate()
store.grant_choices(ACC, c.id, 1)
assert_eq(c.offer, first, "banking another level must not reroll the table")
## Two levels in one run owe two choices. Losing one for doing well is a
## punishment nobody would guess at.
func test_choices_queue() -> void:
var c := store.create_character(ACC, "Fast")
store.grant_choices(ACC, c.id, 3)
assert_eq(c.pending_choices, 3)
store.take_upgrade(ACC, c.id, 0)
assert_eq(c.pending_choices, 2)
assert_eq(c.offer.size(), SimConfig.UPGRADE_CHOICES, "and the next three appear")
func test_taking_an_upgrade_records_it_and_rerolls_the_table() -> void:
var c := store.create_character(ACC, "Taker")
store.grant_choices(ACC, c.id, 1)
var wanted: StringName = c.offer[1]
var taken := store.take_upgrade(ACC, c.id, 1)
assert_eq(taken, wanted)
assert_eq(c.upgrades, [wanted] as Array[StringName])
assert_eq(c.pending_choices, 0)
assert_true(c.offer.is_empty(), "nothing pending means nothing on the table")
func test_taking_without_a_pending_choice_is_refused() -> void:
var c := store.create_character(ACC, "Greedy")
assert_eq(store.take_upgrade(ACC, c.id, 0), &"")
assert_eq(c.upgrades.size(), 0)
## The index arrives from a client, so nonsense has to be a refusal rather than
## a crash or a free upgrade.
func test_an_index_outside_the_offer_is_refused() -> void:
var c := store.create_character(ACC, "Sneaky")
store.grant_choices(ACC, c.id, 1)
assert_eq(store.take_upgrade(ACC, c.id, -1), &"")
assert_eq(store.take_upgrade(ACC, c.id, 99), &"")
assert_eq(c.pending_choices, 1, "and costs nothing")
func test_a_dead_character_cannot_spend_a_choice() -> void:
var c := store.create_character(ACC, "Late")
store.grant_choices(ACC, c.id, 1)
store.retire_character(ACC, c.id)
assert_eq(store.take_upgrade(ACC, c.id, 0), &"")
func test_upgrades_and_pending_choices_survive_a_reload() -> void:
var c := store.create_character(ACC, "Persistent")
store.grant_choices(ACC, c.id, 2)
var taken := store.take_upgrade(ACC, c.id, 0)
var offer := c.offer.duplicate()
var reloaded := CharacterStore.new(store._path)
assert_true(reloaded.load_from_disk())
var got := reloaded.get_character(ACC, c.id)
assert_eq(got.upgrades, [taken] as Array[StringName])
assert_eq(got.pending_choices, 1)
assert_eq(got.offer, offer,
"the table has to survive a restart, or it is a reroll on every crash")
## Upgrades change what a character IS, so the roster has to show the real
## number rather than the one the level alone implies.
func test_the_roster_health_reflects_the_upgrades_taken() -> void:
var c := store.create_character(ACC, "Fragile")
var before := c.max_hp()
c.upgrades.append(Upgrades.GLASS_CANNON)
assert_eq(c.max_hp(), before / 2)
## A save written by a build with an upgrade this one lacks must not leave a
## phantom that counts toward the flat damage bonus and does nothing else.
func test_an_unknown_upgrade_in_a_save_is_dropped() -> void:
var restored := Character.from_dict({
"id": "z", "name": "Old", "xp": 0,
"upgrades": ["sniper", "telekinesis"],
"pending_choices": 2,
"offer": ["poison", "not_a_thing"],
})
assert_eq(restored.upgrades, [Upgrades.SNIPER] as Array[StringName])
assert_eq(restored.offer, [Upgrades.POISON] as Array[StringName])
assert_eq(restored.pending_choices, 2)