d8197885ca
ci / verify (push) Successful in 46s
The roster sent to clients now contains living characters only. Retirement stays server-side bookkeeping for archival; from the player's side a dead character is simply gone, and listing it offers a choice that cannot be taken. The XP bar only moved on a level-up or a character swap because it read the character roster, which is re-sent only when the SET of characters changes. Experience now rides the snapshot -- four bytes on a message already going out at 20Hz -- and the server mirrors each grant into the world immediately rather than only when a level is crossed. The create field starts with a suggested name instead of blank, and offers another after each creation. Two bugs found while testing, both mine: The first was a bad patch of my own: a change meant for the snapshot decoder also matched inside decode_characters, which then read a four-byte field its encoder never wrote and ran off the end of every packet. This is precisely the "encodes but decodes wrong" failure the codec tests exist to catch, and it was caught within a minute of the test being written. Chasing that exposed a real robustness gap: StreamPeerBuffer.get_utf8_string() pushes an engine error and returns garbage when the buffer is short, so a truncated or hostile character/roster packet produced error spam instead of degrading. Both decoders now bounds-check every field, with tests that slice each packet at many lengths and assert it degrades rather than inventing entries -- the same guarantee the input decoder already had. 206 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
168 lines
6.2 KiB
GDScript
168 lines
6.2 KiB
GDScript
extends Node
|
|
## End-to-end check of the progression wiring: kill -> experience -> level ->
|
|
## health, and death -> character retired -> roster offered.
|
|
##
|
|
## godot --headless --path . res://tools/diag_progression.tscn
|
|
##
|
|
## Runs as a scene because ServerRuntime needs the Net autoload, which a
|
|
## --script run does not have. Exits non-zero on any failure, so it can gate.
|
|
##
|
|
## The store side is unit-tested in isolation; what this covers is the wiring
|
|
## between the simulation's events and the account that banks them, which is
|
|
## exactly the part a bot cannot be relied on to exercise (bots are poor shots).
|
|
|
|
const STORE_PATH := "user://diag_progression.json"
|
|
|
|
var _fails: Array[String] = []
|
|
var _step: int = 0
|
|
var _srv: ServerRuntime
|
|
var _account: int = 424242
|
|
var _character: Character
|
|
|
|
|
|
func _ready() -> void:
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
|
|
GameOpts.bot_client = true
|
|
GameOpts.account_override = _account
|
|
if Net.host(27401) != OK:
|
|
push_error("could not host")
|
|
get_tree().quit(1)
|
|
return
|
|
_srv = Net.server
|
|
_srv.store = CharacterStore.new(STORE_PATH)
|
|
_srv.store.load_from_disk()
|
|
Net.start_local_client()
|
|
|
|
|
|
func _check(ok: bool, what: String) -> void:
|
|
if ok:
|
|
print(" ok %s" % what)
|
|
else:
|
|
print(" FAIL %s" % what)
|
|
_fails.append(what)
|
|
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
_step += 1
|
|
if _step == 20:
|
|
_character = _srv.store.last_played(_account)
|
|
_check(_character != null, "a character exists after login")
|
|
if _character == null:
|
|
_finish()
|
|
return
|
|
_check(_character.level == 1, "starts at level 1")
|
|
_check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == _character.id,
|
|
"the peer is playing it")
|
|
|
|
elif _step == 40:
|
|
# Into a dungeon, which is where experience is earned.
|
|
_srv._send_to_dungeon(Net.LOCAL_PEER)
|
|
|
|
elif _step == 60:
|
|
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
|
_check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON,
|
|
"moved into a dungeon")
|
|
if inst == null:
|
|
_finish()
|
|
return
|
|
var before: int = _srv.store.get_character(_account, _character.id).total_xp
|
|
# Kill something the way a hit would: through the world's own damage
|
|
# path, so the event carries the def id the scorer reads.
|
|
var victim: SimEnemy = null
|
|
for e in inst.world.enemies.values():
|
|
if e.alive:
|
|
victim = e
|
|
break
|
|
if victim == null:
|
|
_check(false, "the dungeon had a living enemy to kill")
|
|
_finish()
|
|
return
|
|
victim.hp = 1
|
|
inst.world._damage_enemy(victim, 100)
|
|
_srv._dispatch_events(inst)
|
|
var after: int = _srv.store.get_character(_account, _character.id).total_xp
|
|
_check(after > before, "killing an enemy awards experience (%d -> %d)" % [before, after])
|
|
# Mirrored into the world immediately, which is what lets the bar move
|
|
# per kill instead of waiting for the next roster message.
|
|
var mine: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
|
_check(mine.total_xp == after, "the experience reaches the world at once")
|
|
|
|
elif _step == 80:
|
|
# Enough experience to cross a level boundary, and check health follows.
|
|
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
|
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
|
var hp_before := p.max_hp
|
|
_srv._grant_xp(Net.LOCAL_PEER, Progression.total_xp_for_level(3))
|
|
var c := _srv.store.get_character(_account, _character.id)
|
|
_check(c.level >= 2, "experience produces levels (now %d)" % c.level)
|
|
_check(p.level == c.level, "the player in the world levels with it")
|
|
_check(p.max_hp > hp_before,
|
|
"max health follows the level (%d -> %d)" % [hp_before, p.max_hp])
|
|
_check(p.max_hp == Progression.max_hp_for_level(c.level),
|
|
"and matches the curve exactly")
|
|
|
|
elif _step == 70:
|
|
# Swapping inside a dungeon would be an instant, uninterruptible exit
|
|
# from danger. The server must refuse it whatever the UI allows.
|
|
var before: String = _srv.peer_characters.get(Net.LOCAL_PEER, "")
|
|
_srv.on_select_character(Net.LOCAL_PEER, before)
|
|
_check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == before,
|
|
"a character swap inside a dungeon is refused")
|
|
# Through the server's entry point, not the store's -- the store has no
|
|
# opinion about where you are, and calling it directly would bypass the
|
|
# guard this is checking (and leave a stray character behind).
|
|
var before_count := _srv.store.characters_for(_account).size()
|
|
_srv.on_create_character(Net.LOCAL_PEER, "spare")
|
|
_check(_srv.store.characters_for(_account).size() == before_count,
|
|
"creating a character inside a dungeon is refused too")
|
|
|
|
elif _step == 90:
|
|
# Stop behaving like a bot before the kill. A bot auto-creates a
|
|
# replacement the instant it sees an empty roster -- correct in play,
|
|
# but it would mask exactly what the next steps are checking.
|
|
GameOpts.bot_client = false
|
|
|
|
elif _step == 100:
|
|
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
|
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
|
p.spawn_grace = 0
|
|
p.hp = 1
|
|
inst.world._damage_player(p, 9999)
|
|
_srv._dispatch_events(inst)
|
|
var c := _srv.store.get_character(_account, _character.id)
|
|
_check(c != null, "the dead character is kept, not deleted")
|
|
_check(not c.active, "and is marked inactive")
|
|
_check(c.died_unix > 0, "with a time of death recorded")
|
|
_check(_srv.store.active_characters(_account).is_empty(),
|
|
"no living characters remain")
|
|
|
|
elif _step == 110:
|
|
# The roster the client actually holds must contain no dead character:
|
|
# retirement is server-side bookkeeping, and from the player's side a
|
|
# dead character is simply gone.
|
|
var shown := 0
|
|
for c in Net.client.characters:
|
|
shown += 1
|
|
_check(shown == 0, "the dead character is not offered to the client")
|
|
_check(_srv.store.characters_for(_account).size() == 1,
|
|
"but the server still holds the record")
|
|
|
|
elif _step == 120:
|
|
# With nothing alive, the player must be left with no character rather
|
|
# than silently resurrected into the dead one.
|
|
_check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == "",
|
|
"the player is left without a character to play")
|
|
_check(Net.client.needs_character(),
|
|
"so the client shows the roster screen")
|
|
_finish()
|
|
|
|
|
|
func _finish() -> void:
|
|
print("---")
|
|
if _fails.is_empty():
|
|
print("PROGRESSION_OK")
|
|
else:
|
|
print("PROGRESSION_FAIL (%d)" % _fails.size())
|
|
Net.shutdown()
|
|
get_tree().quit(0 if _fails.is_empty() else 1)
|