Stage 4: upgrades, and a quartermaster to spend them at
ci / verify (push) Successful in 48s

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>
This commit is contained in:
2026-09-06 15:34:58 +02:00
parent a943aa19f6
commit b351bc2d55
47 changed files with 2438 additions and 92 deletions
+3
View File
@@ -98,6 +98,9 @@ const ENEMY_IDLE: Array[Rect2] = [
Rect2(288, 336, 16, 16), # red flask -> practice target
]
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
## The hub's quartermaster. A different character strip from the knight the
## player wears, so an NPC never reads as another player standing still.
const NPC_IDLE := Rect2(128, 36, 16, 28)
## ItemDef.visual -> atlas rect. Order matches Items.ORDER: health potion,
## Warden's ration. Both come from the tileset's flask row -- there is no food
+35 -1
View File
@@ -8,11 +8,17 @@ extends Node2D
@onready var menu: CanvasLayer = $GameMenu
@onready var sfx: Node = $Sfx
@onready var characters: CanvasLayer = $CharacterSelect
@onready var upgrades: CanvasLayer = $UpgradeScreen
var _bound: ClientRuntime = null
## Opened deliberately from the menu, as opposed to forced open by having no
## character to play.
var _roster_open: bool = false
## The quartermaster panel. Opened by walking to the NPC and pressing interact,
## and closed the moment you walk away -- the server refuses a choice made from
## anywhere else, so leaving the panel open at a distance would only offer a
## button that gets rejected.
var _upgrades_open: bool = false
func _ready() -> void:
@@ -27,6 +33,8 @@ func _ready() -> void:
Net.select_character(id))
characters.create_requested.connect(func(n: String) -> void: Net.create_character(n))
characters.closed.connect(func() -> void: _roster_open = false)
upgrades.closed.connect(func() -> void: _upgrades_open = false)
upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i))
var _screen_centre := Vector2.ZERO
@@ -66,7 +74,11 @@ func _process(_delta: float) -> void:
_bound.item_used.connect(func(_i: StringName) -> void:
sfx.play(Art.SFX_SHOOT, -10.0))
_bound.characters_changed.connect(_refresh_characters)
_bound.select_failed.connect(func(why: String) -> void: characters.set_status(why))
_bound.upgrades_changed.connect(_refresh_upgrades)
_refresh_upgrades()
_bound.select_failed.connect(func(why: String) -> void:
characters.set_status(why)
upgrades.set_status(why))
_refresh_characters()
# The roster screen is shown exactly when there is nothing to play: first
# login, or after the last living character died.
@@ -79,11 +91,33 @@ func _process(_delta: float) -> void:
_roster_open = false
characters.visible = forced or _roster_open
characters.set_dismissible(not forced)
if _bound != null and _upgrades_open and not _bound.at_upgrade_npc():
_upgrades_open = false
upgrades.visible = _upgrades_open
_follow_camera()
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action_pressed("interact"):
return
if _bound == null or not _bound.at_upgrade_npc():
return
# Interact is also pick-up. Loot wins, exactly as it does on the server, so
# the key never does one thing here and another there.
if not _bound.loot_in_reach().is_empty():
return
_upgrades_open = not _upgrades_open
_refresh_upgrades()
func _refresh_upgrades() -> void:
if _bound != null:
upgrades.refresh(_bound.upgrades_pending, _bound.upgrade_offer,
_bound.upgrades_taken)
## Routed through the same held-escape channel the F key uses, rather than a
## direct "teleport me" message -- the server has no such message, and adding
## one would hand clients an instant, uninterruptible exit.
+30
View File
@@ -55,6 +55,7 @@ func _draw() -> void:
return
_draw_terrain()
_draw_portals()
_draw_upgrade_npc()
for l in client.ground_loot():
if _visible(l["pos"]):
_draw_loot(l)
@@ -185,6 +186,35 @@ func _draw_portals() -> void:
_draw_portal_label(at, def)
## The quartermaster. Drawn with a prompt rather than left to be discovered:
## the whole upgrade system is behind one unmarked figure in a large room.
func _draw_upgrade_npc() -> void:
if client.instance_kind != Protocol.InstanceKind.LOBBY:
return
var at := client.upgrade_npc
if not _visible(at):
return
var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3))
_draw_sprite(Art.TILESET, src, at, Color.WHITE, false, Art.PLAYER_ANCHOR)
var waiting := client.upgrades_pending
var tint := Color(1.0, 0.85, 0.4) if waiting > 0 else Color(0.65, 0.7, 0.82)
var label := "QUARTERMASTER"
if waiting > 0:
label = "QUARTERMASTER — %d level-up%s to spend" % [
waiting, "" if waiting == 1 else "s"]
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 34.0), label,
HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
if client.at_upgrade_npc():
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0),
"E talk", HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13,
Color(0.8, 0.85, 0.95))
# A pending choice pulses, so it is visible from across the hub.
if waiting > 0:
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.005)
draw_arc(at, 26.0 + 4.0 * pulse, 0.0, TAU, 32,
Color(1.0, 0.85, 0.4, 0.25 + 0.35 * pulse), 2.0)
func _draw_portal_label(at: Vector2, def: DungeonDef) -> void:
var font := ThemeDB.fallback_font
var top := at - Vector2(0.0, SimConfig.PORTAL_RADIUS + 26.0)