Files
transcience/tests/unit/test_interest.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

114 lines
4.8 KiB
GDScript

extends GutTest
## Interest management: what the server declines to send.
##
## Fog is a rendering rule and stops nothing — a modified client draws whatever
## it was handed. These tests cover the part that is actually a defence: actors
## and bullet spawns the server never puts on the wire.
var world: SimWorld
const ME := 1
const OTHER := 2
func before_each() -> void:
var map := MapGrid.new(200, 60, MapGrid.Kind.WALL)
map.fill_rect(Rect2i(1, 1, 198, 58), MapGrid.Kind.FLOOR)
map.centre_on_origin()
world = SimWorld.new(1)
world.set_map(map)
var me := world.add_player(ME, "me")
me.pos = Vector2.ZERO
func _snap_for(peer: int) -> Dictionary:
return NetCodec.decode_snapshot(
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, peer))
func test_a_distant_enemy_is_not_sent_at_all() -> void:
world.spawn_enemy(Content.turret(), Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 300.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 0,
"an enemy across the map must never reach the client")
func test_a_nearby_enemy_is_sent() -> void:
world.spawn_enemy(Content.turret(), Vector2(200.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 1)
func test_enemies_are_visible_well_beyond_what_can_be_seen() -> void:
# The interest radius has to exceed the fog radius, or enemies would appear
# out of nowhere at the exact edge of sight.
assert_gt(SimConfig.ACTOR_INTEREST_RADIUS, SimConfig.FOG_VIEW_RADIUS)
world.spawn_enemy(Content.turret(), Vector2(SimConfig.FOG_VIEW_RADIUS + 50.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 1,
"just out of sight must still be known, so it does not pop in")
func test_a_distant_player_is_not_sent() -> void:
var them := world.add_player(OTHER, "them")
them.pos = Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 400.0, 0.0)
var peers: Array = _snap_for(ME)["players"]
assert_eq(peers.size(), 1)
assert_eq(int(peers[0]["peer"]), ME)
## The one record that can never be filtered: the client reconciles its own
## prediction against it, so dropping it would break the player's own movement
## rather than merely hiding someone.
func test_your_own_player_is_always_sent_however_far_out_you_are() -> void:
world.players[ME].pos = Vector2(1.0e5, 1.0e5)
var peers: Array = _snap_for(ME)["players"]
assert_eq(peers.size(), 1)
assert_eq(int(peers[0]["peer"]), ME)
func test_a_distant_boss_is_not_sent() -> void:
var boss := world.spawn_boss(Content.warden())
boss.pos = Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 500.0, 0.0)
assert_null(_snap_for(ME)["boss"], "you should not see a boss room you are nowhere near")
boss.pos = Vector2(100.0, 0.0)
assert_not_null(_snap_for(ME)["boss"])
func test_omitting_the_observer_encodes_the_whole_world() -> void:
# Peer 0 means "no filtering" — used by tests and as the safe fallback when
# the observer cannot be found.
world.spawn_enemy(Content.turret(), Vector2(9000.0, 0.0))
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
assert_eq((snap["enemies"] as Array).size(), 1)
## The radius that stops bullets winking into existence, checked against the
## real content rather than guessed. Adding a faster or longer-lived bullet
## should fail here rather than produce invisible damage in play.
func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
# The player's shot is the longest-travelling bullet in the game once
# upgrades are involved, so the worst case is measured at the speed ceiling
# rather than at the base speed. test_interest used to model only static
# content, which upgrades quietly invalidated.
var worst := SimConfig.MAX_BULLET_SPEED \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
var emitters: Array[BulletEmitter] = []
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
emitters.append_array(Content.enemy(id).emitters)
for phase in Content.warden().phases:
emitters.append_array(phase.emitters)
for e in emitters:
worst = maxf(worst, e.speed * float(e.lifetime) * SimConfig.TICK_DELTA)
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS,
"a bullet spawned just outside the radius must not be able to reach " +
"ground the player can see before it expires (worst travel %.0f)" % worst)
## The ceiling is what makes the radius above computable at all. Without it a
## stack of Snipers would push bullet travel past whatever radius was chosen,
## and the failure would be invisible damage rather than a failing test.
func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void:
var ids: Array[StringName] = []
for _i in 8:
ids.append(Upgrades.SNIPER)
var travel := PlayerStats.build(ids).bullet_speed \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS)