Files
transcience/src/sim/player_stats.gd
T
claude b351bc2d55
ci / verify (push) Successful in 48s
Stage 4: upgrades, and a quartermaster to spend them at
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>
2026-09-06 15:34:58 +02:00

79 lines
3.1 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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