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:
@@ -0,0 +1,59 @@
|
||||
class_name UpgradeDef
|
||||
extends Resource
|
||||
## One upgrade, as data. Every field here is a modifier the simulation already
|
||||
## knows how to apply, so adding an upgrade is a table entry and never a branch
|
||||
## in [SimWorld] -- the same rule bosses follow.
|
||||
##
|
||||
## Note what is NOT here: the flat +5% damage every upgrade carries. That is a
|
||||
## property of *taking an upgrade*, not of any particular one, so it lives in
|
||||
## [PlayerStats] where it is applied once per upgrade held. Baking it into each
|
||||
## definition would mean seven places to change it and seven places to get it
|
||||
## wrong.
|
||||
|
||||
enum Rarity { COMMON, UNCOMMON, RARE, LEGENDARY }
|
||||
|
||||
@export var id: StringName = &"upgrade"
|
||||
@export var display_name: String = "Upgrade"
|
||||
## Shown on the choice card. Says what it does, in the player's terms.
|
||||
@export var description: String = ""
|
||||
@export var rarity: Rarity = Rarity.COMMON
|
||||
|
||||
# --- Damage -----------------------------------------------------------------
|
||||
## Added to the additive pool: base x (1 + sum of these) x product of the
|
||||
## multiplicative ones. Negative for the upgrades that trade damage away.
|
||||
@export var damage_add: float = 0.0
|
||||
## Multiplied in after the pool. Sniper is the only thing that uses this, and
|
||||
## the brief calls it out as multiplicative specifically so it stays sharp no
|
||||
## matter how many additive percentages have piled up.
|
||||
@export var damage_mult: float = 1.0
|
||||
|
||||
# --- Everything else --------------------------------------------------------
|
||||
## Multiplies the character's levelled maximum health, so the cost of trading
|
||||
## health away scales instead of fading out by level 15.
|
||||
@export var max_hp_mult: float = 1.0
|
||||
## Below 1.0 means slower. Applied to the cooldown as a division, so 0.5 here is
|
||||
## genuinely half the shots per second.
|
||||
@export var fire_rate_mult: float = 1.0
|
||||
@export var bullet_speed_mult: float = 1.0
|
||||
## Extra projectiles fanned out to the sides of the aim.
|
||||
@export var side_shots: int = 0
|
||||
## Extra projectiles parallel to the aim, offset sideways.
|
||||
@export var parallel_shots: int = 0
|
||||
## How many times one shot may split on hitting something. The brief's "cannot
|
||||
## split twice unless the upgrade is taken again" is exactly this being a count
|
||||
## rather than a flag.
|
||||
@export var split_charges: int = 0
|
||||
## Fraction of a hit's damage dealt again over the poison window.
|
||||
@export var poison_fraction: float = 0.0
|
||||
## Chance, per tick, that a shot deletes an enemy projectile it is passing
|
||||
## through.
|
||||
@export var erase_chance: float = 0.0
|
||||
|
||||
|
||||
static func rarity_name(r: Rarity) -> String:
|
||||
match r:
|
||||
Rarity.COMMON: return "common"
|
||||
Rarity.UNCOMMON: return "uncommon"
|
||||
Rarity.RARE: return "rare"
|
||||
Rarity.LEGENDARY: return "legendary"
|
||||
return "?"
|
||||
Reference in New Issue
Block a user