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:
@@ -280,3 +280,63 @@ drop you into the other's run purely on timing.
|
||||
zero health is a crash waiting for a divide — and a boosted drop chance never
|
||||
exceeds certain, or the roll becomes dead code and "chance" stops meaning
|
||||
anything.
|
||||
|
||||
---
|
||||
|
||||
## Upgrades
|
||||
|
||||
**Damage is `base × (1 + Σ additive) × Π multiplicative`.** The flat +5% every
|
||||
upgrade carries, Spread's −10%, Doubleshot's −50% and Glass Cannon's +100% pool
|
||||
into the additive term; Sniper's ×2 multiplies the result. The brief called
|
||||
Sniper out as multiplicative specifically, and this is what that buys: it stays
|
||||
worth taking however many percentages have already piled up, and two Snipers is
|
||||
4× rather than +200%.
|
||||
|
||||
**The +5% belongs to *taking an upgrade*, not to any particular upgrade.** It
|
||||
lives in `SimConfig.UPGRADE_DAMAGE_BONUS` and is applied once per upgrade held,
|
||||
rather than being baked into seven definitions where it would be seven places
|
||||
to get wrong.
|
||||
|
||||
**Stats are derived from the upgrade list, never stored.** `PlayerStats.build()`
|
||||
recomputes them from the ids the character holds, the same way `Progression`
|
||||
derives level from experience. One source of truth means a saved stat can never
|
||||
disagree with the upgrades that produced it.
|
||||
|
||||
**Glass Cannon halves the LEVELLED maximum health, and multiplies if taken
|
||||
twice.** Half of base HP would be a flat −50 that fades from brutal at level 1
|
||||
to nearly free at level 15, which is the wrong direction for a drawback.
|
||||
|
||||
**Poison doses stack independently rather than refreshing.** Chosen over the
|
||||
alternatives knowing it is by far the strongest reading: at ~4.3 shots/sec that
|
||||
is dozens of concurrent doses. It costs nothing to run because every dose lasts
|
||||
the same number of ticks — so they expire in the order they were added, the
|
||||
pending expiries are a plain FIFO, and only its front is ever examined.
|
||||
|
||||
**Split Shot's children leave at ±45° from the original heading**, 90° apart,
|
||||
and are born just past the target rather than on it. A child spawned inside
|
||||
what 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.
|
||||
|
||||
**Level-ups queue.** Reaching two levels in one run owes two choices. Losing one
|
||||
for doing well is a punishment nobody would guess at.
|
||||
|
||||
**The offer is rolled once and held on the character.** If it regenerated when
|
||||
the screen opened, closing and reopening would be a free reroll and everyone
|
||||
would press it until a legendary turned up. It is written to disk with
|
||||
everything else, so a crash is not a reroll either.
|
||||
|
||||
**Upgrades are spent standing at the hub NPC, enforced on the server.** Same
|
||||
rule as the dungeon portal: where a player is standing is the one thing a
|
||||
modified client cannot fake. The screen closes when you walk away so it never
|
||||
offers a button that would be refused.
|
||||
|
||||
**Bullet speed has a hard ceiling.** Wall collision samples a position once per
|
||||
tick, so anything faster than one tile per tick tunnels through geometry. Two
|
||||
Snipers would ask for 2480 u/s against a 1920 threshold, and a tunnelling
|
||||
bullet looks exactly like a bullet — so `MAX_BULLET_SPEED` clamps it and a test
|
||||
pins the clamp.
|
||||
|
||||
**Poison ticks are not announced as hits.** They land many times a second on a
|
||||
reliable channel, and the client learns enemy health from the snapshot anyway.
|
||||
Death is still announced, because the experience award is keyed on that event
|
||||
and a kill by poison has to score.
|
||||
|
||||
Reference in New Issue
Block a user