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:
+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