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.
|
||||
|
||||
@@ -257,6 +257,27 @@ Pickup shares `BTN_INTERACT` with the dungeon portal. Loot wins when both are in
|
||||
reach, but only on a tick where something was actually taken, so a full bag
|
||||
cannot leave a player standing on the portal unable to use it.
|
||||
|
||||
## Upgrades change what a shot is, and none of it is client-side
|
||||
|
||||
A player's damage, fire rate, bullet speed, shot count and every rider
|
||||
(splitting, poison, erasure) come from `PlayerStats`, rebuilt on the server from
|
||||
the upgrade ids the character holds. The client builds its own copy for the
|
||||
choice screen's summary, and that copy decides nothing — it exists so the panel
|
||||
can show real numbers instead of promises.
|
||||
|
||||
Three things fall out of it worth writing down:
|
||||
|
||||
- **The riders live on the bullet, not on the shooter.** A shot already in
|
||||
flight keeps what it was fired with, so taking Poison mid-flight cannot reach
|
||||
back and poison it. They stay zero on a replica, which never resolves a hit
|
||||
and has no use for them.
|
||||
- **Choosing an upgrade is intent, checked three ways.** The client sends an
|
||||
index; the server checks a choice is owed, that the index names one of the
|
||||
three options *it* put on the table, and that the player is standing at the
|
||||
hub NPC. The index selects from a list the client did not write.
|
||||
- **The offer is server state.** Rolled once, stored on the character, and
|
||||
persisted — so closing the screen is not a reroll, and neither is a crash.
|
||||
|
||||
## No contact damage
|
||||
|
||||
Nothing hurts you by touching it. Every threat is a bullet you can see and
|
||||
@@ -294,6 +315,8 @@ transition, not a combat mechanic.
|
||||
| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side |
|
||||
| Item action repeated by a held key | edge-triggered against `prev_buttons` / `prev_slot` |
|
||||
| Inventory slot index out of range | `SimPlayer.take_slot` answers "nothing" |
|
||||
| Upgrade choice made anywhere but the hub NPC | `SimWorld.at_upgrade_npc`, server-side position |
|
||||
| Upgrade index outside the server's own offer | `CharacterStore.take_upgrade` refuses |
|
||||
|
||||
The respawn lockout is worth calling out: the HUD disables its button for the
|
||||
same three seconds, but that is presentation. A client that ignores its own UI
|
||||
|
||||
+78
-50
@@ -20,15 +20,17 @@ What "everything passes" currently means. Numbers move; the shape does not.
|
||||
| Gate | Covers | Runtime |
|
||||
| --- | --- | --- |
|
||||
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
||||
| `tools/test.sh` | 288 GUT tests, no SceneTree | ~3s |
|
||||
| `tools/test.sh` | 357 GUT tests, no SceneTree | ~4s |
|
||||
| `tools/smoke.sh` | 18 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||||
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
|
||||
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s |
|
||||
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
|
||||
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted | ~10s |
|
||||
|
||||
The three diagnostics exist because the smoke test structurally cannot reach
|
||||
what they cover: bots are poor shots (so they neither level up nor produce
|
||||
drops), and a listen server cannot drift its own clock against itself.
|
||||
The four diagnostics exist because the smoke test structurally cannot reach
|
||||
what they cover: bots are poor shots (so they neither level up, produce drops,
|
||||
nor walk to the quartermaster), and a listen server cannot drift its own clock
|
||||
against itself.
|
||||
|
||||
---
|
||||
|
||||
@@ -217,49 +219,78 @@ shares the interact key.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Upgrades · *todo, blocked on decisions*
|
||||
## Stage 4 — Upgrades · *done*
|
||||
|
||||
**Do not start this without answering the open questions below.** The damage
|
||||
formula in particular determines the shape of every upgrade.
|
||||
Every level gained banks one choice. Choices are spent at the **quartermaster**,
|
||||
an NPC in the hub: walk to it, press E, pick one of three.
|
||||
|
||||
### The mechanism
|
||||
| Feature | State | Where |
|
||||
| --- | --- | --- |
|
||||
| Upgrades as data, no engine branches | done | [src/content/upgrades.gd](../src/content/upgrades.gd), [upgrade_def.gd](../src/actors/upgrades/upgrade_def.gd) |
|
||||
| Per-player derived stats | done | [src/sim/player_stats.gd](../src/sim/player_stats.gd) |
|
||||
| 3 weighted options per choice, held until spent | done | `Upgrades.roll_offer`, stored on `Character.offer` |
|
||||
| Choices queue across levels | done | `Character.pending_choices` |
|
||||
| Spent only at the NPC, enforced server-side | done | `SimWorld.at_upgrade_npc`, `ServerRuntime.on_choose_upgrade` |
|
||||
| Choice screen showing the +5% and each effect | done | [src/ui/upgrade_screen.gd](../src/ui/upgrade_screen.gd) |
|
||||
| List of upgrades already taken, plus resulting stats | done | lower half of the same panel |
|
||||
| Upgrades persist, die with the character | done | `Character.upgrades`, permadeath retires the record |
|
||||
|
||||
- An NPC in the hub. Each level gained grants one choice.
|
||||
- The choice offers **3 random upgrades**.
|
||||
- **Every upgrade also carries a +5% damage buff, additive** ("adaptively
|
||||
scaling" in the brief — read as additive, confirm if wrong).
|
||||
- The choice screen must show that buff **and** all the upgrade's other effects.
|
||||
- A separate screen lists the upgrades already taken.
|
||||
|
||||
### The upgrades, as specified
|
||||
### The seven upgrades
|
||||
|
||||
| Upgrade | Rarity | Effect |
|
||||
| --- | --- | --- |
|
||||
| Split shot | common | Hitting an enemy spawns 2 of the same bullet at a 45° angle behind the enemy. A shot cannot split twice unless the upgrade is taken again. |
|
||||
| Glass cannon | common | +100% damage, −50% health. |
|
||||
| Spread | common | Adds 2 side projectiles in a cone. −10% damage. |
|
||||
| Sniper | common | 2× damage (**multiplicative, not additive**), 0.5× fire rate, 2× bullet speed. |
|
||||
| Doubleshot | rarer than common | Adds 1 projectile firing parallel to the others. −50% damage. |
|
||||
| Poison | rare | Each projectile deals an additional 50% of its damage over the next 10 seconds. |
|
||||
| Eraser | legendary | Shots have a 1% chance to delete a projectile they pass through. |
|
||||
| Split Shot | common | On hitting something, two more of the same shot leave it at ±45°. One charge each; stacks. |
|
||||
| Glass Cannon | common | +100% damage, ×0.5 levelled max health. |
|
||||
| Spread | common | +2 shots in a cone, −10% damage. |
|
||||
| Sniper | common | ×2 damage (multiplicative), ×0.5 fire rate, ×2 bullet speed. |
|
||||
| Doubleshot | uncommon | +1 parallel shot, −50% damage. |
|
||||
| Poison | rare | Each hit deals another 50% of its damage over 10s. Doses stack independently. |
|
||||
| Eraser | legendary | 1% chance per tick to delete an enemy projectile a shot is passing through. |
|
||||
|
||||
Upgrades stack — "cannot split twice *unless upgraded again*" says so directly.
|
||||
Draw weights: common 100, uncommon 45, rare 18, legendary 4.
|
||||
|
||||
### What this implies
|
||||
### The decisions behind the numbers
|
||||
|
||||
Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`. It becomes a
|
||||
per-player computed stat, so `SimWorld._fire_player_shot` grows a stats block.
|
||||
Split, spread and doubleshot all change how many bullets a shot produces, so
|
||||
they belong in the same place.
|
||||
All four settled with the user; the reasoning is in
|
||||
[DECISIONS.md](DECISIONS.md).
|
||||
|
||||
Two constraints already pinned by tests that upgrades will collide with:
|
||||
- **Damage is `base × (1 + Σ additive) × Π multiplicative`.** The flat +5% every
|
||||
upgrade carries, Spread's −10%, Doubleshot's −50% and Glass Cannon's +100%
|
||||
pool additively; Sniper multiplies the result, which is why two Snipers is 4×
|
||||
and not +200%.
|
||||
- **Glass Cannon halves the LEVELLED maximum**, so the price does not fade to a
|
||||
rounding error by level 15. Taken twice it multiplies again.
|
||||
- **Poison doses are independent**, not refreshed. `PoisonTrack` makes that free:
|
||||
every dose lasts the same number of ticks, so they expire in insertion order,
|
||||
the pending expiries are a plain FIFO, and the per-tick cost is O(1) however
|
||||
many are live.
|
||||
- **Split Shot's children leave at ±45° from the original heading**, born just
|
||||
past the target so they cannot be resolved against it again on the same tick.
|
||||
|
||||
- **Sniper doubles bullet speed.** `test_bullet_speeds_stay_below_the_tunnelling_threshold`
|
||||
asserts that even at 2× a bullet stays under one tile per tick. Stacking two
|
||||
snipers would break wall collision, so the multiplier needs a ceiling.
|
||||
- **Longer/faster bullets widen `BULLET_INTEREST_RADIUS`.** `test_interest.gd`
|
||||
recomputes the floor from live content; upgrades change bullet travel *per
|
||||
player*, which that test does not currently model.
|
||||
### Constraints this collided with, and what it cost
|
||||
|
||||
- **Bullet speed now has a ceiling** (`SimConfig.MAX_BULLET_SPEED`, 1500 u/s).
|
||||
Wall collision samples once per tick, so anything over one tile per tick
|
||||
(1920 u/s) tunnels. Two Snipers would have asked for 2480.
|
||||
- **`BULLET_INTEREST_RADIUS` rose to 2900**, because an upgraded player shot is
|
||||
now the longest-travelling bullet in the game. `test_interest.gd` measures the
|
||||
worst case from the ceiling rather than from static content, which is what it
|
||||
used to do and what upgrades quietly invalidated.
|
||||
- **Poison ticks do not emit `ENEMY_HIT`.** They land many times a second on a
|
||||
reliable channel; the client learns hp from the snapshot. Death is still
|
||||
announced, because the experience award is keyed on it.
|
||||
- **The eraser's bullet-vs-bullet scan costs nothing until someone owns it** —
|
||||
it runs only for shots carrying a non-zero chance.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- **The +5% is invisible on the first upgrade.** `PLAYER_BULLET_DAMAGE` is 6, so
|
||||
6 × 1.05 rounds back to 6. It accumulates correctly (14 upgrades → +70% → 10
|
||||
damage), but the first one visibly does nothing. See the open question below.
|
||||
- **One panel, not two screens.** The brief asked for a separate screen listing
|
||||
upgrades taken; it is the lower half of the choice panel instead, because the
|
||||
two are read together.
|
||||
- **No respec, and no way to see another player's build.**
|
||||
|
||||
---
|
||||
|
||||
@@ -306,21 +337,18 @@ Not oversights — each was considered and rejected for now, with the reasoning
|
||||
Genuinely unspecified. **Do not guess at these** — each changes the design, and
|
||||
several have no obvious default.
|
||||
|
||||
### Blocking Stage 4 (upgrades)
|
||||
### Worth a decision soon
|
||||
|
||||
1. **Damage stacking order.** Sniper is explicitly multiplicative; the +5% per
|
||||
upgrade and the ± percentages read as additive. Is it
|
||||
`base × (1 + Σ additive) × Π multiplicative`, or something else?
|
||||
2. **Rarity weights** for common / rarer / rare / legendary.
|
||||
3. **Split shot geometry** — ±22.5° from the original heading (45° total), or
|
||||
45° to each side (90° total)?
|
||||
4. **Poison stacking** — do applications stack, or does a new hit refresh one
|
||||
damage-over-time effect?
|
||||
5. **Eraser's target** — does it delete *enemy bullets* it passes through?
|
||||
6. **Do unclaimed level-ups queue?** Reaching level 4 and 5 inside one run:
|
||||
two pending choices at the NPC, or one?
|
||||
7. **Glass cannon's −50% health** — of base HP, or of the character's levelled
|
||||
maximum?
|
||||
1. **Base damage is too small for the +5% to show.** `PLAYER_BULLET_DAMAGE` is
|
||||
6, and 6 × 1.05 rounds to 6 — so the flat bonus every upgrade carries does
|
||||
nothing at all until the second one. It comes out right in aggregate, but a
|
||||
player who takes Split Shot and sees no damage change has been told
|
||||
something untrue.
|
||||
|
||||
The clean fix is to scale base damage and enemy health together — ×4 would
|
||||
put a shot at 24 and make every 5% step land — which changes no time-to-kill
|
||||
but touches every number in `content.gd`. That is a balance edit, so it is
|
||||
yours to call rather than mine.
|
||||
|
||||
### Blocking nothing yet
|
||||
|
||||
|
||||
Reference in New Issue
Block a user