b351bc2d55
ci / verify (push) Successful in 48s
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>
70 lines
3.2 KiB
GDScript
70 lines
3.2 KiB
GDScript
class_name Protocol
|
|
extends RefCounted
|
|
## Wire constants. Bump [constant VERSION] whenever a codec layout changes; the
|
|
## server refuses mismatched clients at handshake rather than desyncing later.
|
|
|
|
## 2: added spawn-grace flag + cleared countdown to the snapshot, and the
|
|
## roster message.
|
|
## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte
|
|
## to each player record.
|
|
## 4: added SimEvent.Type.PLAYER_FIRED. It was inserted mid-enum, which shifts
|
|
## the wire value of every event after it -- a mismatched client would
|
|
## mis-decode every hit and death, so the handshake has to reject it.
|
|
## 5: handshake carries an auth ticket instead of a bare name; added character
|
|
## list/select/create messages, per-player max health and colour in the
|
|
## snapshot.
|
|
## 6: inventory and loot. The snapshot gained the observer's own inventory and
|
|
## the ground-loot list, the input frame gained a slot byte, and three item
|
|
## events were appended. Every one of those changes the byte layout of a
|
|
## message both ends parse positionally.
|
|
## 7: more than one dungeon. enter_instance carries a portal LIST and the id of
|
|
## the dungeon you are standing in, replacing the single portal position.
|
|
## 8: upgrades. A new server -> client upgrade-state message, a new
|
|
## client -> server choice message, and two more SelectResult values.
|
|
const VERSION := 8
|
|
const DEFAULT_PORT := 27015
|
|
const MAX_CLIENTS := 32
|
|
|
|
## ENet channels. Separating them stops a burst of reliable bullet events from
|
|
## head-of-line blocking the unreliable snapshot stream.
|
|
const CH_CONTROL := 1 ## handshake, instance transitions -- reliable
|
|
const CH_SNAPSHOT := 2 ## world state -- unreliable, newest wins
|
|
const CH_EVENTS := 3 ## bullet spawns/despawns, hits -- reliable ordered
|
|
const CH_INPUT := 4 ## client -> server intent -- unreliable ordered
|
|
## Must be identical on both ends, and larger than the highest channel above.
|
|
const CHANNEL_COUNT := 8
|
|
|
|
enum InstanceKind { LOBBY, DUNGEON }
|
|
|
|
## Why a character selection failed. Sent rather than a bare "no", so the UI can
|
|
## say something useful instead of appearing broken.
|
|
enum SelectResult {
|
|
OK,
|
|
NO_SUCH_CHARACTER,
|
|
CHARACTER_IS_DEAD,
|
|
LIMIT_REACHED,
|
|
NOT_AUTHENTICATED,
|
|
## Swapping is hub-only. Allowing it inside a dungeon would be an instant,
|
|
## uninterruptible exit from danger -- strictly better than the one-second
|
|
## escape channel, and it would make that channel pointless.
|
|
NOT_IN_HUB,
|
|
## Upgrades are spent standing at the hub's NPC. Same rule as the portal:
|
|
## where you are is the one thing a modified client cannot fake.
|
|
NOT_AT_THE_NPC,
|
|
## No unspent level-up, or an option index that was not on the table.
|
|
NOTHING_TO_CHOOSE,
|
|
}
|
|
|
|
## Player flags packed into the snapshot's per-player byte.
|
|
const F_ALIVE := 1
|
|
const F_ESCAPING := 4
|
|
## Arrival protection: invulnerable and unable to shoot. The only invulnerable
|
|
## state there is -- see the i-frames note in SimConfig.
|
|
const F_SPAWN_GRACE := 8
|
|
## The peer behind this player has dropped and is being channelled out.
|
|
const F_LINKDEAD := 16
|
|
|
|
## Snapshot's cleared-countdown byte when the instance is not in the cleared
|
|
## state -- 30s fits in a byte, so 255 is free to mean "not applicable".
|
|
const COUNTDOWN_NONE := 255
|