7ef972e3b3
ci / verify (push) Successful in 47s
Character swapping is hub-only, and refused by the SERVER rather than merely greyed out in the menu. Allowing it inside a dungeon would be an instant, uninterruptible exit from danger -- strictly better than the one-second escape channel, which would make that channel pointless. Creating a character in a dungeon is refused for the same reason. Both are covered by diag_progression. Health regenerates at 0.5% of MAXIMUM per second. A percentage rather than a flat rate so it does not become irrelevant at level 15: a capped character regains 1.2 hp/s against a level 1's 0.5, and both take about 200 seconds to heal from nothing. No out-of-combat gate -- at this rate it cannot out-heal anything actually shooting at you, and a trickle that never stops is easier to reason about than a timer players have to learn. A fractional carry is needed because a tick heals well under one hit point, so truncating each tick would heal exactly nothing; there is a test for that specifically. The XP bar now states the percentage and the level it leads to, since a bar answers "how far" vaguely and a number answers it exactly. Recorded the two Stage 3 answers: 4 inventory slots, and the boss's food item is player-instanced now so the mechanism gets exercised rather than deferred. Writing the swap guard's test caught my own mistake: the first version created its spare character through the store, which has no opinion about where you are, so it bypassed the guard it was meant to prove and left a stray character behind that broke a later assertion. 200 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
153 lines
5.5 KiB
GDScript
153 lines
5.5 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])
|
|
|
|
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 == 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)
|