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
+32
View File
@@ -20,6 +20,19 @@ var team := PackedByteArray()
var kind := PackedByteArray()
var alive := PackedByteArray()
# --- Player upgrade riders, server-only -------------------------------------
# Carried on the bullet rather than looked up from the shooter, because a shot
# already in flight keeps the properties it was fired with -- levelling up
# mid-flight must not retroactively poison a bullet that left before you took
# Poison. All three stay 0 on a client replica, which never resolves a hit and
# so has no use for them.
## Times this shot may still split on hitting something.
var split := PackedInt32Array()
## Fraction of this shot's damage to apply again as poison.
var poison := PackedFloat32Array()
## Chance per tick that this shot deletes an enemy projectile it overlaps.
var erase := PackedFloat32Array()
## The geometry bullets die against. Set by the owning SimWorld; identical on
## server and client, which is what keeps the replica in step.
var map: MapGrid = null
@@ -58,6 +71,9 @@ func _init() -> void:
team.resize(n)
kind.resize(n)
alive.resize(n)
split.resize(n)
poison.resize(n)
erase.resize(n)
func clear() -> void:
@@ -97,6 +113,11 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
team[slot] = bullet_team
kind[slot] = bullet_kind
alive[slot] = 1
# Cleared on every spawn, so a reused slot never inherits the last
# occupant's upgrades.
split[slot] = 0
poison[slot] = 0.0
erase[slot] = 0.0
if forced_uid != 0:
uid[slot] = forced_uid
else:
@@ -107,6 +128,17 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
return slot
## Attach a shooter's upgrade riders. Called immediately after spawn() by the
## server; never by the replica.
func set_mods(slot: int, split_charges: int, poison_fraction: float,
erase_chance: float) -> void:
if slot < 0:
return
split[slot] = split_charges
poison[slot] = poison_fraction
erase[slot] = erase_chance
func clear_spawn_log() -> void:
spawn_log.clear()
wall_kill_log.clear()
+6
View File
@@ -52,12 +52,17 @@ static func _build_lobby() -> Dictionary:
if not markers["T"].is_empty():
var m: Vector2i = markers["T"][0]
target = grid.tile_centre(m.x, m.y)
var npc := grid.tile_centre(size.x * 3 / 4, size.y / 2)
if not markers["U"].is_empty():
var m: Vector2i = markers["U"][0]
npc = grid.tile_centre(m.x, m.y)
return {
"grid": grid,
"rooms": [] as Array[Rect2i],
"spawn": spawn,
"portals": portals,
"dummy": target,
"npc": npc,
"boss_pos": Vector2.ZERO,
"boss_room": Rect2i(),
}
@@ -148,6 +153,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
"spawn": spawn,
"portals": [] as Array[SimPortal],
"dummy": Vector2.ZERO,
"npc": Vector2.ZERO,
"boss_pos": boss_pos,
"boss_room": boss_room,
}
+78
View File
@@ -0,0 +1,78 @@
class_name PlayerStats
extends RefCounted
## A player's combat numbers, computed from the upgrades they hold.
##
## Derived, never stored: the character record keeps the list of upgrade ids and
## this is rebuilt from it. One source of truth means a saved stat can never
## disagree with the upgrades that produced it -- the same reasoning that makes
## [Progression] derive level from experience rather than storing both.
## The damage formula, settled with the user:
##
## base x (1 + sum of additive) x product of multiplicative
##
## The +5% every upgrade carries, Spread's 10%, Doubleshot's 50% and Glass
## Cannon's +100% all pool into the additive term. Sniper's 2x multiplies the
## result, which is why it stays worth taking however many percentages have
## already piled up -- and why two Snipers is 4x rather than +200%.
var damage: int = SimConfig.PLAYER_BULLET_DAMAGE
var fire_cooldown: int = SimConfig.PLAYER_FIRE_COOLDOWN
var bullet_speed: float = SimConfig.PLAYER_BULLET_SPEED
## Multiplies the level's maximum health. See SimPlayer.recompute_max_hp().
var max_hp_mult: float = 1.0
var side_shots: int = 0
var parallel_shots: int = 0
var split_charges: int = 0
var poison_fraction: float = 0.0
var erase_chance: float = 0.0
## How many upgrades produced these numbers, duplicates included. Only used for
## display, but it is the number a player counts.
var upgrade_count: int = 0
## True when the bullet-speed ceiling actually bit. Surfaced so the choice
## screen can say a second Sniper buys no more speed instead of silently
## selling one.
var speed_capped: bool = false
static func build(ids: Array[StringName]) -> PlayerStats:
var s := PlayerStats.new()
var additive := 0.0
var multiplicative := 1.0
var fire_rate := 1.0
var speed := 1.0
var hp := 1.0
for id in ids:
var def := Upgrades.get_def(id)
if def == null:
continue # an upgrade this build no longer has: ignored, not fatal
s.upgrade_count += 1
# Every upgrade carries this, whatever else it does.
additive += SimConfig.UPGRADE_DAMAGE_BONUS
additive += def.damage_add
multiplicative *= def.damage_mult
fire_rate *= def.fire_rate_mult
speed *= def.bullet_speed_mult
hp *= def.max_hp_mult
s.side_shots += def.side_shots
s.parallel_shots += def.parallel_shots
s.split_charges += def.split_charges
s.poison_fraction += def.poison_fraction
s.erase_chance += def.erase_chance
# Floored at 1 rather than allowed to reach zero. Stacking Doubleshot and
# Spread can in principle drive the additive pool below 100%, and a shot
# that deals nothing is indistinguishable from a bug.
s.damage = maxi(1, roundi(float(SimConfig.PLAYER_BULLET_DAMAGE)
* (1.0 + additive) * multiplicative))
# A division, so "half the fire rate" is half the shots per second rather
# than half the cooldown.
s.fire_cooldown = maxi(1, roundi(float(SimConfig.PLAYER_FIRE_COOLDOWN)
/ maxf(fire_rate, 0.01)))
var wanted := SimConfig.PLAYER_BULLET_SPEED * speed
s.bullet_speed = minf(wanted, SimConfig.MAX_BULLET_SPEED)
s.speed_capped = wanted > SimConfig.MAX_BULLET_SPEED
s.max_hp_mult = maxf(hp, 0.01)
s.erase_chance = clampf(s.erase_chance, 0.0, 1.0)
return s
+1
View File
@@ -0,0 +1 @@
uid://57ld2euau2ua
+70
View File
@@ -0,0 +1,70 @@
class_name PoisonTrack
extends RefCounted
## Damage over time on one actor, from any number of independent doses.
##
## The user chose independent stacks: every poisoned hit starts its own full
## window rather than refreshing a shared one, so a fast weapon can have dozens
## running at once. That could have meant walking dozens of timers per enemy per
## tick, and it does not, because every dose lasts exactly the same number of
## ticks:
##
## - the total damage per tick is one running float, [member rate];
## - doses therefore expire in the order they were added, so the pending
## expiries are a plain FIFO and only the front of it is ever examined.
##
## The result is O(1) per tick per actor no matter how many doses are live,
## which is what makes "43 concurrent stacks" a non-event rather than a budget.
## Damage per tick, summed over every live dose.
var rate: float = 0.0
## Fractional damage carried between ticks. A single dose is far under one hit
## point per tick, so without this it would round to nothing forever.
var _carry: float = 0.0
var _expiry := PackedInt32Array()
var _rate := PackedFloat32Array()
var _head: int = 0
## Add a dose worth [param total_damage] spread over the poison window.
func add(total_damage: float, at_tick: int) -> void:
if total_damage <= 0.0:
return
var per_tick := total_damage / float(SimConfig.POISON_DURATION_TICKS)
_expiry.append(at_tick + SimConfig.POISON_DURATION_TICKS)
_rate.append(per_tick)
rate += per_tick
## Retire finished doses and return whole hit points to apply this tick.
func step(at_tick: int) -> int:
while _head < _expiry.size() and _expiry[_head] <= at_tick:
rate -= _rate[_head]
_head += 1
if _head >= _expiry.size():
# Everything expired. Reset rather than subtract to zero: the running
# sum accumulates float error, and "nothing is poisoned" has to mean
# exactly nothing.
_expiry.clear()
_rate.clear()
_head = 0
rate = 0.0
_carry = 0.0
return 0
if rate <= 0.0:
return 0
_carry += rate
if _carry < 1.0:
return 0
var whole := int(_carry)
_carry -= float(whole)
return whole
func active() -> bool:
return rate > 0.0
## Live doses, for tests and the debug overlay.
func dose_count() -> int:
return _expiry.size() - _head
+1
View File
@@ -0,0 +1 @@
uid://vuiinq8qng3i
+8
View File
@@ -15,6 +15,14 @@ var phase_tick: int = 0
## player can always disengage by walking out -- which is the trade for the
## boss room having no door that locks.
var room := Rect2()
## Poison doses ticking on the boss. Lazy for the same reason enemies' are.
var poison: PoisonTrack = null
func poison_track() -> PoisonTrack:
if poison == null:
poison = PoisonTrack.new()
return poison
func hp_fraction() -> float:
+9
View File
@@ -19,6 +19,15 @@ var target_dir := Vector2.ZERO
## sight. Refreshed by the world every tick; null means idle, which is the
## normal state for most of a dungeon.
var target: SimPlayer = null
## Poison doses ticking on this enemy. Built lazily: most enemies in a dungeon
## are never poisoned, and most runs never see the upgrade at all.
var poison: PoisonTrack = null
func poison_track() -> PoisonTrack:
if poison == null:
poison = PoisonTrack.new()
return poison
func hp_fraction() -> float:
+18 -2
View File
@@ -42,6 +42,10 @@ var inventory: Array[StringName] = []
var prev_buttons: int = 0
var prev_slot: int = -1
## Combat numbers derived from the character's upgrades. Rebuilt whenever the
## upgrade list changes, never stored on disk -- see [PlayerStats].
var stats := PlayerStats.new()
## The peer's connection dropped, but the player is deliberately still in the
## world. Held here rather than deleted so a disconnect cannot be used to dodge
## a dangerous moment: a linkdead player keeps channelling the escape (and stays
@@ -86,7 +90,7 @@ func can_fire() -> bool:
## SimConfig.SPAWN_GRACE_TICKS for a dungeon.
func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
pos = spawn
max_hp = Progression.max_hp_for_level(level)
recompute_max_hp()
hp = max_hp
alive = true
spawn_grace = grace
@@ -120,11 +124,23 @@ func adopt(c: Character) -> void:
level = c.level
total_xp = c.total_xp
colour = c.colour
max_hp = c.max_hp()
stats = PlayerStats.build(c.upgrades)
recompute_max_hp()
hp = mini(hp, max_hp)
set_inventory(c.inventory)
## Maximum health is the level's value scaled by whatever the upgrades do to it
## -- Glass Cannon halves it. Multiplying the LEVELLED value rather than the
## base is what keeps that trade a real price at level 15 instead of a rounding
## error. Floored at 1: an upgrade must never be able to make you unkillable by
## making you already dead.
func recompute_max_hp() -> void:
max_hp = maxi(1, roundi(float(Progression.max_hp_for_level(level))
* stats.max_hp_mult))
hp = mini(hp, max_hp)
## Replace the whole inventory, padding or trimming to the configured slot
## count so a save file written when the game had a different number of slots
## still loads into a valid player.
+141 -12
View File
@@ -37,6 +37,11 @@ var map: MapGrid = null
## A list rather than a single position because which dungeon an entrance opens
## is the whole reason there is more than one.
var portals: Array[SimPortal] = []
## Where the hub's upgrade NPC stands. Only meaningful when
## [member has_upgrade_npc] is true -- the map is centred on the origin, so
## Vector2.ZERO is a real position and cannot double as "there isn't one".
var upgrade_npc := Vector2.ZERO
var has_upgrade_npc: bool = false
var spawn_point := Vector2(0.0, 240.0)
## Arrival protection granted to players entering this world. 0 in the hub,
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
@@ -131,6 +136,14 @@ func portal_at(at: Vector2) -> SimPortal:
return best
## Whether [param at] is close enough to spend a level-up. Checked on the
## server for the same reason the portal is: where a player is standing is the
## one thing a modified client cannot fake.
func at_upgrade_npc(at: Vector2) -> bool:
return has_upgrade_npc \
and at.distance_to(upgrade_npc) <= SimConfig.UPGRADE_NPC_RADIUS
func alive_player_count() -> int:
var n := 0
for p in players.values():
@@ -186,6 +199,7 @@ func step() -> void:
_step_boss()
pool.step()
_resolve_bullet_hits()
_step_poison()
_emit_wall_kill_events()
_emit_spawn_events()
else:
@@ -276,18 +290,44 @@ func _button_edge(p: SimPlayer, frame: InputFrame) -> int:
return edge
## One trigger pull. How many bullets that is, and what they carry, comes
## entirely from the shooter's [PlayerStats] -- there is no per-upgrade branch
## here, which is what lets a new upgrade be a table entry in [Upgrades].
func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
p.fire_cooldown = p.stats.fire_cooldown
events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id})
var dir := Vector2.RIGHT.rotated(p.aim)
pool.spawn(
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
dir * SimConfig.PLAYER_BULLET_SPEED,
var forward := Vector2.RIGHT.rotated(p.aim)
var muzzle := p.pos + forward * SimConfig.PLAYER_MUZZLE_OFFSET
# The aimed shot plus Spread's cone, alternating sides so an odd count still
# comes out symmetric about the aim.
_spawn_player_bullet(p, muzzle, forward)
for i in p.stats.side_shots:
var step := float(i / 2 + 1) * SimConfig.SPREAD_STEP_DEG
var side_sign := 1.0 if i % 2 == 0 else -1.0
_spawn_player_bullet(p, muzzle,
forward.rotated(deg_to_rad(step * side_sign)))
# Doubleshot's extras travel parallel rather than fanned, so they are an
# offset at the muzzle and not an angle.
for i in p.stats.parallel_shots:
var gap := float(i / 2 + 1) * SimConfig.PARALLEL_OFFSET
var lateral_sign := 1.0 if i % 2 == 0 else -1.0
_spawn_player_bullet(p, muzzle + forward.orthogonal() * gap * lateral_sign,
forward)
func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void:
var slot := pool.spawn(
at,
dir * p.stats.bullet_speed,
SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME,
SimConfig.PLAYER_BULLET_DAMAGE,
p.stats.damage,
SimConfig.TEAM_PLAYER,
SimConfig.KIND_PLAYER_SHOT)
pool.set_mods(slot, p.stats.split_charges, p.stats.poison_fraction,
p.stats.erase_chance)
# --- Items and loot ---------------------------------------------------------
@@ -592,7 +632,12 @@ func _resolve_bullet_hits() -> void:
var consumed := false
if boss != null and boss.alive \
and Movement.circles_overlap(bp, br, boss.pos, boss.def.radius):
_damage_boss(pool.damage[i])
# Poison is taken from the damage the boss ACTUALLY took, so a
# phase that takes 30% extra is poisoned 30% harder too.
var applied := _damage_boss(pool.damage[i])
if pool.poison[i] > 0.0:
boss.poison_track().add(float(applied) * pool.poison[i], tick)
_split_shot(i, boss.pos, boss.def.radius)
consumed = true
if not consumed:
for e in enemies.values():
@@ -600,10 +645,82 @@ func _resolve_bullet_hits() -> void:
continue
if Movement.circles_overlap(bp, br, e.pos, e.def.radius):
_damage_enemy(e, pool.damage[i])
if pool.poison[i] > 0.0:
e.poison_track().add(
float(pool.damage[i]) * pool.poison[i], tick)
_split_shot(i, e.pos, e.def.radius)
consumed = true
break
if consumed:
_kill_bullet(i)
elif pool.erase[i] > 0.0:
_try_erase(i)
## Split Shot: two children of the shot that just landed, leaving the target at
## 45 degrees to either side.
##
## They are born just PAST the target rather than on it. A child spawned inside
## the thing that was just hit would be resolved against it again on the same
## tick -- a free second hit, and with several charges a free chain of them.
##
## Children inherit the remaining lifetime rather than a fresh one, so splitting
## cannot extend a shot's reach indefinitely; a split at the end of a shot's
## life produces two short-lived children, which is the conservative reading of
## "two of the same bullet".
func _split_shot(slot: int, from: Vector2, target_radius: float) -> void:
if pool.split[slot] <= 0:
return
var v: Vector2 = pool.vel[slot]
var speed := v.length()
if speed < 0.001:
return
var charges := pool.split[slot] - 1
var clearance := target_radius + pool.radius[slot] + 2.0
for side_sign in [1.0, -1.0]:
var dir := (v / speed).rotated(deg_to_rad(SimConfig.SPLIT_ANGLE_DEG) * side_sign)
var child := pool.spawn(from + dir * clearance, dir * speed,
pool.radius[slot], pool.life[slot], pool.damage[slot],
pool.team[slot], pool.kind[slot])
pool.set_mods(child, charges, pool.poison[slot], pool.erase[slot])
## Eraser: at most ONE roll per shot per tick, against the first enemy
## projectile it is overlapping.
##
## Rolling once per overlapping pair would multiply the stated 1% by however
## many bullets happened to occupy the same place, which in a boss ring is a
## lot. One opportunity per tick, taken or not, keeps the number the player was
## promised close to the number they get.
##
## The scan is O(bullets) per erasing shot, and runs only for shots that carry
## the upgrade -- which is why it is worth nothing until a legendary is drawn.
func _try_erase(slot: int) -> void:
var p: Vector2 = pool.pos[slot]
var r: float = pool.radius[slot]
for j in pool.high_water:
if pool.alive[j] == 0 or pool.team[j] != SimConfig.TEAM_ENEMY:
continue
if not Movement.circles_overlap(p, r, pool.pos[j], pool.radius[j]):
continue
if rng.randf() < pool.erase[slot]:
_kill_bullet(j)
return
## One tick of every live poison dose. Cheap regardless of how many are running
## -- see [PoisonTrack] for why.
func _step_poison() -> void:
for e in enemies.values():
if not e.alive or e.poison == null:
continue
var dealt: int = e.poison.step(tick)
if dealt > 0:
_damage_enemy(e, dealt, true)
if boss != null and boss.alive and boss.poison != null:
var on_boss: int = boss.poison.step(tick)
if on_boss > 0:
_damage_boss(on_boss, true, false)
## Bullets removed early must be announced -- clients cannot derive a hit.
@@ -624,9 +741,14 @@ func _damage_player(p: SimPlayer, amount: int) -> void:
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
func _damage_enemy(e: SimEnemy, amount: int) -> void:
## [param silent] suppresses the ENEMY_HIT event. Poison ticks use it: they
## land many times a second on the reliable channel, and the client learns hp
## from the snapshot anyway. Death is still announced either way, because that
## is what the experience award is keyed on.
func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
e.hp = maxi(e.hp - amount, 0)
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
if not silent:
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
if e.hp <= 0:
e.alive = false
_drop_loot(e.def.loot, e.pos)
@@ -635,16 +757,23 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void:
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
func _damage_boss(amount: int) -> void:
## Returns the damage actually applied, after the phase's armour multiplier, so
## the caller can derive poison from the same number.
##
## [param scale] is false for poison ticks, whose damage was already scaled when
## the dose was applied -- scaling again would compound the multiplier.
func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int:
var phase := boss.current_phase()
var mult := 1.0 if phase == null else phase.damage_taken_mult
var mult := 1.0 if phase == null or not scale else phase.damage_taken_mult
var applied := maxi(1, roundi(float(amount) * mult))
boss.hp = maxi(boss.hp - applied, 0)
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
if not silent:
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
if boss.hp <= 0:
boss.alive = false
_drop_loot(boss.def.loot, boss.pos)
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
return applied
## Bullets that died against geometry. A client is only streamed the map near