diff --git a/CLAUDE.md b/CLAUDE.md index e4e57f7..90e3fdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,13 +26,15 @@ non-zero on failure, so they gate like tests: godot --headless --path . res://tools/diag_prediction.tscn # prediction gap; injects clock drift godot --headless --path . res://tools/diag_progression.tscn # kill -> xp -> level -> death -> roster godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up -> persist -> use -> drop +godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats godot --headless --path . --script tools/bench.gd # sim cost per tick python3 tools/build_local_assets.py # rebuild the local-only bullet atlas ``` -`diag_progression` and `diag_loot` exist because the bot smoke test cannot cover -either: bots are poor shots, so they rarely kill anything, which means they -neither earn levels nor produce drops. +`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke +test cannot cover any of them: bots are poor shots, so they rarely kill +anything, which means they neither earn levels, produce drops, nor ever reach +the quartermaster. Everything after `--` goes to `GameOpts.parse()`: @@ -89,7 +91,7 @@ and `tests/integration/test_replica_parity.gd` pin this down. | Path | What lives there | | --- | --- | | `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. | -| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`. Shapes, not instances. | +| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. | | `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. | | `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. | | `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. | @@ -98,6 +100,7 @@ and `tests/integration/test_replica_parity.gd` pin this down. | `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. | | `src/content/items.gd` | All items, same idea. `Items.ORDER` is the wire format — append only. | | `src/content/dungeons.gd` | The kinds of run. `Dungeons.ORDER` is both a wire format and the hub's portal order. | +| `src/content/upgrades.gd` | The seven upgrades and their draw weights. `Upgrades.ORDER` is a wire format. | | `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. | | `src/instances/` | Lobby hub and dungeon runs. | | `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. | @@ -158,8 +161,15 @@ ticks in milliseconds with no SceneTree. any client regenerate the whole dungeon. `MAP_STREAM_RADIUS` must stay wider than `FOG_VIEW_RADIUS`, or prediction runs on terrain the client lacks. - **Bullet speed must stay under one tile per tick.** Wall collision samples - position once per tick, so anything faster tunnels. Pinned by + position once per tick, so anything faster tunnels. Upgrades multiply bullet + speed, so `SimConfig.MAX_BULLET_SPEED` clamps the result — without it two + Snipers put shots through walls. Pinned by `test_bullet_speeds_stay_below_the_tunnelling_threshold`. +- **A player's combat numbers are derived, never stored.** `PlayerStats.build()` + recomputes them from the character's upgrade ids every time, so 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 must keep what it was fired with. - **Only `ServerRuntime` writes progression and persistence.** The simulation reads a player's level and max health, and moves items between the ground and a bag; it never grants experience, retires a character, or touches the store. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f9347a5..c6cd7ca 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. diff --git a/docs/NETCODE.md b/docs/NETCODE.md index 20372a2..299d0c2 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d74ae8a..96eb8b0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/src/actors/upgrades/upgrade_def.gd b/src/actors/upgrades/upgrade_def.gd new file mode 100644 index 0000000..e09e843 --- /dev/null +++ b/src/actors/upgrades/upgrade_def.gd @@ -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 "?" diff --git a/src/actors/upgrades/upgrade_def.gd.uid b/src/actors/upgrades/upgrade_def.gd.uid new file mode 100644 index 0000000..67b339e --- /dev/null +++ b/src/actors/upgrades/upgrade_def.gd.uid @@ -0,0 +1 @@ +uid://d0mhvxc26ff0 diff --git a/src/autoload/net.gd b/src/autoload/net.gd index 03f8189..dd6b583 100644 --- a/src/autoload/net.gd +++ b/src/autoload/net.gd @@ -169,13 +169,13 @@ func send_welcome(peer_id: int) -> void: ## hack with no work required; tiles are streamed instead (send_map_chunks). func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int, boss_id: String, spawn: Vector2, map_w: int, map_h: int, - portals: PackedByteArray, dungeon: String) -> void: + portals: PackedByteArray, dungeon: String, npc: Vector2) -> void: if _is_local(peer_id): client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, - map_h, portals, dungeon) + map_h, portals, dungeon, npc) else: s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn, - map_w, map_h, portals, dungeon) + map_w, map_h, portals, dungeon, npc) func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void: @@ -213,6 +213,13 @@ func send_select_result(peer_id: int, result: int, reason: String) -> void: s_select_result.rpc_id(peer_id, result, reason) +func send_upgrades(peer_id: int, data: PackedByteArray) -> void: + if _is_local(peer_id): + client.on_upgrades(data) + else: + s_upgrades.rpc_id(peer_id, data) + + func send_roster(peer_id: int, data: PackedByteArray) -> void: if _is_local(peer_id): client.on_roster(data) @@ -234,6 +241,16 @@ func select_character(character_id: String) -> void: c_select_character.rpc_id(1, character_id) +## Spend a level-up. Intent only: the index names one of the three options the +## SERVER put on the table, and the server checks the player is standing at the +## NPC before it means anything. +func choose_upgrade(index: int) -> void: + if server != null: + server.on_choose_upgrade(LOCAL_PEER, index) + elif state == State.ONLINE: + c_choose_upgrade.rpc_id(1, index) + + func create_character(character_name: String) -> void: if server != null: server.on_create_character(LOCAL_PEER, character_name) @@ -271,6 +288,13 @@ func c_create_character(character_name: String) -> void: server.on_create_character(multiplayer.get_remote_sender_id(), character_name) +@rpc("any_peer", "call_remote", "reliable", 1) +func c_choose_upgrade(index: int) -> void: + if server == null: + return + server.on_choose_upgrade(multiplayer.get_remote_sender_id(), index) + + @rpc("any_peer", "call_remote", "unreliable_ordered", 4) func c_input(data: PackedByteArray) -> void: if server == null: @@ -291,11 +315,11 @@ func s_welcome(peer_id: int, _version: int) -> void: @rpc("authority", "call_remote", "reliable", 1) func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, spawn: Vector2, map_w: int, map_h: int, portals: PackedByteArray, - dungeon: String) -> void: + dungeon: String, npc: Vector2) -> void: if client == null: return client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, - map_h, portals, dungeon) + map_h, portals, dungeon, npc) @rpc("authority", "call_remote", "reliable", 1) @@ -323,6 +347,12 @@ func s_select_result(result: int, reason: String) -> void: client.on_select_result(result, reason) +@rpc("authority", "call_remote", "reliable", 1) +func s_upgrades(data: PackedByteArray) -> void: + if client != null: + client.on_upgrades(data) + + @rpc("authority", "call_remote", "reliable", 1) func s_roster(data: PackedByteArray) -> void: if client != null: diff --git a/src/content/rooms.gd b/src/content/rooms.gd index c55adaa..95ea929 100644 --- a/src/content/rooms.gd +++ b/src/content/rooms.gd @@ -22,10 +22,11 @@ const LEGEND := { "P": MapGrid.Kind.FLOOR, "S": MapGrid.Kind.FLOOR, "T": MapGrid.Kind.FLOOR, + "U": MapGrid.Kind.FLOOR, } ## Legend characters that record a position rather than only painting a tile. -const MARKERS := ["B", "D", "P", "S", "T"] +const MARKERS := ["B", "D", "P", "S", "T", "U"] ## Wide, with pillars to break the Warden's rings and pits that shape where you @@ -99,6 +100,7 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict ## The hub. Hand-authored like the boss arenas. ## ## P a dungeon portal S player spawn T practice target +## U the upgrade NPC ## ## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER. Two ## of them now: the real run and the Proving Grounds, side by side so they can @@ -116,7 +118,7 @@ static func lobby() -> PackedStringArray: "#.......................................#", "#.......................................#", "#.......................................#", - "#.........T.............................#", + "#.........T...................U.........#", "#.......................................#", "#.......................................#", "#.......................................#", diff --git a/src/content/upgrades.gd b/src/content/upgrades.gd new file mode 100644 index 0000000..2026301 --- /dev/null +++ b/src/content/upgrades.gd @@ -0,0 +1,165 @@ +class_name Upgrades +extends RefCounted +## Every upgrade in the game, in code, like the rest of `src/content/`. +## +## [constant ORDER] is a wire format: an upgrade's index is the byte that rides +## the upgrade-state message and the character save. Append, never reorder. + +const SPLIT_SHOT := &"split_shot" +const GLASS_CANNON := &"glass_cannon" +const SPREAD := &"spread" +const SNIPER := &"sniper" +const DOUBLESHOT := &"doubleshot" +const POISON := &"poison" +const ERASER := &"eraser" + +const ORDER: Array[StringName] = [ + SPLIT_SHOT, + GLASS_CANNON, + SPREAD, + SNIPER, + DOUBLESHOT, + POISON, + ERASER, +] + +## Draw weights by rarity. Relative, not percentages -- what matters is that a +## legendary is a story and a common is Tuesday. +const WEIGHTS := { + UpgradeDef.Rarity.COMMON: 100, + UpgradeDef.Rarity.UNCOMMON: 45, + UpgradeDef.Rarity.RARE: 18, + UpgradeDef.Rarity.LEGENDARY: 4, +} + + +static func get_def(id: StringName) -> UpgradeDef: + match id: + SPLIT_SHOT: return split_shot() + GLASS_CANNON: return glass_cannon() + SPREAD: return spread() + SNIPER: return sniper() + DOUBLESHOT: return doubleshot() + POISON: return poison() + ERASER: return eraser() + return null + + +## Wire value. 0 is reserved for "no upgrade", so an id's value is its position +## plus one -- same convention as [Items]. +static func index_of(id: StringName) -> int: + var at := ORDER.find(id) + return at + 1 if at >= 0 else 0 + + +static func by_index(index: int) -> StringName: + if index <= 0 or index > ORDER.size(): + return &"" + return ORDER[index - 1] + + +## Pick [param count] distinct upgrades, weighted by rarity. +## +## Distinct within one offer only -- an upgrade you already hold can and should +## come up again, because taking Split Shot twice is how a shot splits twice. +static func roll_offer(rng: RandomNumberGenerator, count: int) -> Array[StringName]: + var pool := ORDER.duplicate() + var picked: Array[StringName] = [] + while picked.size() < count and not pool.is_empty(): + var total := 0 + for id in pool: + total += _weight_of(id) + var roll := rng.randi_range(1, maxi(total, 1)) + for i in pool.size(): + roll -= _weight_of(pool[i]) + if roll <= 0: + picked.append(pool[i]) + pool.remove_at(i) + break + return picked + + +static func _weight_of(id: StringName) -> int: + var def := get_def(id) + return int(WEIGHTS.get(def.rarity, 1)) if def != null else 1 + + +# --- The upgrades ----------------------------------------------------------- + +static func split_shot() -> UpgradeDef: + var u := UpgradeDef.new() + u.id = SPLIT_SHOT + u.display_name = "Split Shot" + u.description = "Hitting an enemy spawns two more of the same shot, at 45° to either side, behind it. Take it again to split again." + u.rarity = UpgradeDef.Rarity.COMMON + u.split_charges = 1 + return u + + +static func glass_cannon() -> UpgradeDef: + var u := UpgradeDef.new() + u.id = GLASS_CANNON + u.display_name = "Glass Cannon" + u.description = "Double damage, half health. The health is half of whatever your level gives you, so it stays a real price." + u.rarity = UpgradeDef.Rarity.COMMON + u.damage_add = 1.0 + u.max_hp_mult = 0.5 + return u + + +static func spread() -> UpgradeDef: + var u := UpgradeDef.new() + u.id = SPREAD + u.display_name = "Spread" + u.description = "Two more shots in a cone either side of your aim. −10% damage." + u.rarity = UpgradeDef.Rarity.COMMON + u.side_shots = 2 + u.damage_add = -0.10 + return u + + +## The only multiplicative damage source, and the only thing that touches bullet +## speed. Both are capped downstream: PlayerStats clamps the speed under the +## tunnelling threshold, because a bullet that moves more than a tile per tick +## walks straight through walls. +static func sniper() -> UpgradeDef: + var u := UpgradeDef.new() + u.id = SNIPER + u.display_name = "Sniper" + u.description = "Double damage, multiplied on top of everything else. Half the fire rate, double the bullet speed." + u.rarity = UpgradeDef.Rarity.COMMON + u.damage_mult = 2.0 + u.fire_rate_mult = 0.5 + u.bullet_speed_mult = 2.0 + return u + + +static func doubleshot() -> UpgradeDef: + var u := UpgradeDef.new() + u.id = DOUBLESHOT + u.display_name = "Doubleshot" + u.description = "One more shot travelling parallel to the rest. −50% damage." + u.rarity = UpgradeDef.Rarity.UNCOMMON + u.parallel_shots = 1 + u.damage_add = -0.50 + return u + + +static func poison() -> UpgradeDef: + var u := UpgradeDef.new() + u.id = POISON + u.display_name = "Poison" + u.description = "Every shot deals another 50% of its damage over the next 10 seconds. Stacks with itself — every hit adds another dose." + u.rarity = UpgradeDef.Rarity.RARE + u.poison_fraction = 0.5 + return u + + +static func eraser() -> UpgradeDef: + var u := UpgradeDef.new() + u.id = ERASER + u.display_name = "Eraser" + u.description = "Your shots have a 1% chance to delete an enemy projectile they pass through." + u.rarity = UpgradeDef.Rarity.LEGENDARY + u.erase_chance = 0.01 + return u diff --git a/src/content/upgrades.gd.uid b/src/content/upgrades.gd.uid new file mode 100644 index 0000000..5c251c3 --- /dev/null +++ b/src/content/upgrades.gd.uid @@ -0,0 +1 @@ +uid://c4s2g0bi42sko diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index e2a8536..47557f5 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -117,8 +117,36 @@ const ESCAPE_CHANNEL_TICKS := 60 # 1 second ## connection now runs the same one-second channel (see SimPlayer.linkdead), ## which only works if being shot cannot cancel it. +# --- Upgrades --------------------------------------------------------------- +## Damage every upgrade adds on top of whatever else it does, into the additive +## pool. A property of taking an upgrade rather than of any one upgrade, so it +## lives here and is applied once per upgrade held. +const UPGRADE_DAMAGE_BONUS := 0.05 +## Options offered per level gained. +const UPGRADE_CHOICES := 3 +## Angle between consecutive shots in a Spread cone. +const SPREAD_STEP_DEG := 13.0 +## Sideways gap between Doubleshot's parallel projectiles. +const PARALLEL_OFFSET := 15.0 +## How far to either side a Split Shot child leaves the enemy it was born on. +const SPLIT_ANGLE_DEG := 45.0 +## How long one dose of Poison takes to deliver its damage. +const POISON_DURATION_TICKS := 600 # 10 seconds +## How close to the hub's upgrade NPC you must stand to spend a choice. +## Enforced on the server, like the portal: standing somewhere is the only +## thing a client cannot lie about. +const UPGRADE_NPC_RADIUS := 70.0 + # --- Bullets ---------------------------------------------------------------- const MAX_BULLETS := 4096 +## Hard ceiling on any bullet, however many speed multipliers stack up. +## +## Wall collision samples a bullet's position once per tick, so anything faster +## than one tile (MapGrid.TILE = 32px) per tick tunnels straight through +## geometry. At 60Hz that threshold is 1920 u/s; this leaves a margin for the +## sampling to stay honest. Two Snipers would ask for 2480 and get this instead. +## Pinned by test_bullet_pool.gd. +const MAX_BULLET_SPEED := 1500.0 const TEAM_PLAYER := 0 const TEAM_ENEMY := 1 @@ -161,7 +189,9 @@ const ACTOR_INTEREST_RADIUS := 800.0 ## test_interest.gd computes that from the real content and asserts this covers ## it, so adding a faster or longer-lived bullet fails a test instead of ## producing bullets that wink into existence. -const BULLET_INTEREST_RADIUS := 2200.0 +## Raised for upgrades: a Sniper's shot travels MAX_BULLET_SPEED * lifetime, +## which is further than anything the enemy content fires. +const BULLET_INTEREST_RADIUS := 2900.0 # --- Map streaming ---------------------------------------------------------- ## How far around a player the server streams map tiles. Comfortably wider than diff --git a/src/game.tscn b/src/game.tscn index 322c597..2d159ee 100644 --- a/src/game.tscn +++ b/src/game.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=8 format=3] +[gd_scene load_steps=9 format=3] [ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"] [ext_resource type="Script" path="res://src/view/world_view.gd" id="2"] @@ -7,6 +7,7 @@ [ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"] [ext_resource type="Script" path="res://src/view/sfx.gd" id="6"] [ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"] +[ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"] [node name="Game" type="Node2D"] script = ExtResource("1") @@ -29,3 +30,6 @@ script = ExtResource("6") [node name="CharacterSelect" type="CanvasLayer" parent="."] script = ExtResource("7") + +[node name="UpgradeScreen" type="CanvasLayer" parent="."] +script = ExtResource("8") diff --git a/src/instances/instance.gd b/src/instances/instance.gd index e17fea8..a05288e 100644 --- a/src/instances/instance.gd +++ b/src/instances/instance.gd @@ -44,6 +44,8 @@ static func make_lobby(instance_id: int) -> Instance: var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0) inst.world.set_map(built["grid"]) inst.world.portals = built["portals"] + inst.world.upgrade_npc = built["npc"] + inst.world.has_upgrade_npc = true inst.world.spawn_point = built["spawn"] inst.state = State.ACTIVE # A single inert practice target so players can feel out the gun before diff --git a/src/meta/character.gd b/src/meta/character.gd index e7135b9..647abb8 100644 --- a/src/meta/character.gd +++ b/src/meta/character.gd @@ -28,6 +28,22 @@ var died_unix: int = 0 ## bags, and so a server restart does not quietly confiscate everyone's potions. var inventory: Array[StringName] = [] +## Upgrades taken, in the order they were taken. Duplicates are meaningful -- +## two Split Shots really is two splits -- so this is a list, not a set. +## +## The derived numbers (damage, fire rate, health multiplier) are NOT stored: +## [PlayerStats] rebuilds them from this list, so a saved stat can never +## disagree with the upgrades that produced it. +var upgrades: Array[StringName] = [] +## Level-ups not yet spent at the hub NPC. They queue: reaching two levels in +## one run owes you two choices, because losing one for doing well is a +## punishment nobody would guess at. +var pending_choices: int = 0 +## The choices currently on the table, held so that walking away and coming back +## shows the same three. Without this, closing and reopening the screen would be +## a free reroll until a legendary turned up. +var offer: Array[StringName] = [] + ## Suggested names, offered when creating a character so the field is never ## blank. Deliberately a pair of short word lists rather than a big table: the @@ -80,8 +96,12 @@ static func sanitize_name(raw: String) -> String: return out if not out.is_empty() else "adventurer" +## What this character actually walks around with: the level's health, scaled by +## whatever the upgrades do to it. Shown on the roster screen, so it has to be +## the real number and not the pre-upgrade one. func max_hp() -> int: - return Progression.max_hp_for_level(level) + return maxi(1, roundi(float(Progression.max_hp_for_level(level)) + * PlayerStats.build(upgrades).max_hp_mult)) func xp_progress() -> float: @@ -120,9 +140,19 @@ func to_dict() -> Dictionary: # Items.ORDER being appended to, and a human editing it should be able # to tell what a character is carrying. "inventory": _inventory_ids(), + "upgrades": _name_list(upgrades), + "pending_choices": pending_choices, + "offer": _name_list(offer), } +static func _name_list(ids: Array[StringName]) -> Array: + var out := [] + for id in ids: + out.append(String(id)) + return out + + func _inventory_ids() -> Array: var out := [] for item in inventory: @@ -150,9 +180,24 @@ static func from_dict(d: Dictionary) -> Character: var carried_id := StringName(String(raw)) carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE) c.set_inventory(carried) + # Unknown upgrade ids are dropped rather than kept as dead entries, so a + # save from a build with an upgrade this one lacks still produces coherent + # stats instead of a phantom that counts toward the +5% and does nothing. + c.upgrades = _known_upgrades(d.get("upgrades", [])) + c.pending_choices = maxi(int(d.get("pending_choices", 0)), 0) + c.offer = _known_upgrades(d.get("offer", [])) return c +static func _known_upgrades(raw: Array) -> Array[StringName]: + var out: Array[StringName] = [] + for entry in raw: + var id := StringName(String(entry)) + if Upgrades.get_def(id) != null: + out.append(id) + return out + + ## Replace the whole inventory, padded or trimmed to the current slot count. func set_inventory(items: Array[StringName]) -> void: inventory.resize(SimConfig.INVENTORY_SLOTS) diff --git a/src/meta/character_store.gd b/src/meta/character_store.gd index 79a2e9e..cb56164 100644 --- a/src/meta/character_store.gd +++ b/src/meta/character_store.gd @@ -116,6 +116,47 @@ func set_inventory(account_id: int, character_id: String, items: Array[StringNam save() +## Award level-up choices and, if nothing is on the table yet, roll one. +## +## The offer is rolled HERE and stored, not generated on demand when the screen +## opens: an offer that regenerated per request would be a free reroll, and a +## player would simply close and reopen until a legendary appeared. +func grant_choices(account_id: int, character_id: String, count: int) -> void: + var c := get_character(account_id, character_id) + if c == null or count <= 0: + return + c.pending_choices += count + _refresh_offer(c) + save() + + +## Spend one choice on [param index] of the character's current offer. Returns +## the upgrade taken, or an empty id if the choice was not available -- the +## caller is the server, and "not available" is a refusal, not an error. +func take_upgrade(account_id: int, character_id: String, index: int) -> StringName: + var c := get_character(account_id, character_id) + if c == null or not c.active or c.pending_choices <= 0: + return &"" + if index < 0 or index >= c.offer.size(): + return &"" + var chosen := c.offer[index] + c.upgrades.append(chosen) + c.pending_choices -= 1 + # Cleared before re-rolling, so the next choice is a fresh three rather than + # the two that were passed over. + c.offer.clear() + _refresh_offer(c) + save() + return chosen + + +func _refresh_offer(c: Character) -> void: + if c.pending_choices > 0 and c.offer.is_empty(): + c.offer = Upgrades.roll_offer(_rng, SimConfig.UPGRADE_CHOICES) + elif c.pending_choices <= 0: + c.offer.clear() + + func grant_xp(account_id: int, character_id: String, amount: int) -> int: var c := get_character(account_id, character_id) if c == null: diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 8d496b0..2923f8f 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -23,6 +23,9 @@ signal item_dropped(item: StringName) ## The account's character roster changed: created, selected, levelled or died. signal characters_changed signal select_failed(reason: String) +## The played character's upgrade state changed: a level banked a choice, or one +## was spent. +signal upgrades_changed var my_peer: int = 0 var instance_id: int = 0 @@ -95,6 +98,15 @@ var portals: Array[Dictionary] = [] ## and the boss's health ceiling -- an easier dungeon's boss has less of it, and ## a bar computed from the unscaled definition would sit near empty all fight. var dungeon_id: StringName = &"" +## Where the hub's upgrade NPC stands. Only meaningful in the hub. +var upgrade_npc := Vector2.ZERO + +## Upgrade state for the played character, server-pushed. The client never +## invents an entry and never rolls an offer -- the three on the table were +## chosen by the server and held there, so closing the screen is not a reroll. +var upgrades_pending: int = 0 +var upgrade_offer: Array[StringName] = [] +var upgrades_taken: Array[StringName] = [] ## Offset the view applies when drawing the world: screen = world + this. ## Published by the game scene every frame, rather than assumed, so aiming @@ -339,6 +351,30 @@ func needs_character() -> bool: return characters_known and selected_character.is_empty() +func on_upgrades(data: PackedByteArray) -> void: + var decoded := NetCodec.decode_upgrade_state(data) + upgrades_pending = int(decoded["pending"]) + upgrade_offer = decoded["offer"] + upgrades_taken = decoded["taken"] + upgrades_changed.emit() + hud_dirty.emit() + + +## True when the player is close enough for the server to accept a choice. The +## same radius the server checks, so the screen is never open on a choice that +## would be refused. +func at_upgrade_npc() -> bool: + return instance_kind == Protocol.InstanceKind.LOBBY \ + and predicted_pos.distance_to(upgrade_npc) <= SimConfig.UPGRADE_NPC_RADIUS + + +## The player's live combat numbers, rebuilt from the upgrades the server says +## they hold. Display only -- the server computes its own copy and that is the +## one that decides anything. +func stats() -> PlayerStats: + return PlayerStats.build(upgrades_taken) + + func on_roster(data: PackedByteArray) -> void: roster = NetCodec.decode_roster(data) hud_dirty.emit() @@ -346,10 +382,11 @@ func on_roster(data: PackedByteArray) -> void: func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, spawn: Vector2, map_w: int, map_h: int, portals_data: PackedByteArray, - dungeon: String) -> void: + dungeon: String, npc: Vector2) -> void: instance_id = id instance_kind = kind as Protocol.InstanceKind portals = NetCodec.decode_portals(portals_data) + upgrade_npc = npc dungeon_id = StringName(dungeon) # Scaled the same way the server scaled it, so the boss bar reads as a # fraction of the health this particular run's boss actually has. diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index f66b938..ffe67ae 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -462,6 +462,55 @@ static func decode_portals(data: PackedByteArray) -> Array[Dictionary]: return out +# --- Upgrade state ---------------------------------------------------------- +# Sent to one peer when its played character's upgrades change: a level gained, +# a choice spent, a character swapped in. Cold, and about the played character +# only -- nobody needs to know what anyone else has taken. + +static func encode_upgrade_state(pending: int, offer: Array[StringName], + taken: Array[StringName]) -> PackedByteArray: + var b := StreamPeerBuffer.new() + b.big_endian = false + b.put_u8(clampi(pending, 0, 255)) + b.put_u8(mini(offer.size(), 255)) + for id in offer: + b.put_u8(Upgrades.index_of(id)) + b.put_u16(mini(taken.size(), 65535)) + for id in taken: + b.put_u8(Upgrades.index_of(id)) + return b.data_array + + +## Returns { "pending": int, "offer": Array[StringName], "taken": Array[StringName] }. +static func decode_upgrade_state(data: PackedByteArray) -> Dictionary: + var offer: Array[StringName] = [] + var taken: Array[StringName] = [] + var out := {"pending": 0, "offer": offer, "taken": taken} + if data.size() < 2: + return out + var b := StreamPeerBuffer.new() + b.big_endian = false + b.data_array = data + out["pending"] = b.get_u8() + var offer_count := b.get_u8() + for _i in offer_count: + if b.get_available_bytes() < 1: + return out + var id := Upgrades.by_index(b.get_u8()) + if not id.is_empty(): + offer.append(id) + if b.get_available_bytes() < 2: + return out + var taken_count := b.get_u16() + for _i in taken_count: + if b.get_available_bytes() < 1: + return out + var id := Upgrades.by_index(b.get_u8()) + if not id.is_empty(): + taken.append(id) + return out + + # --- Character roster ------------------------------------------------------- # Sent once at login and after any change. Low frequency and carries strings, # like the online roster, so it is the same fixed-header-then-utf8 shape. diff --git a/src/net/protocol.gd b/src/net/protocol.gd index ce93310..a9c119e 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -19,7 +19,9 @@ extends RefCounted ## 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. -const VERSION := 7 +## 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 @@ -46,6 +48,11 @@ enum SelectResult { ## 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. diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index c50f8e7..e0ec1a2 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -307,9 +307,63 @@ func _enter_world_as(peer_id: int, c: Character) -> void: p.adopt(c) GameLog.info("server", "peer %d playing '%s' (level %d)" % [peer_id, c.display_name, c.level]) + _send_upgrades(peer_id) _broadcast_roster() +## Spend one level-up on one of the three options the server put on the table. +## +## Everything about this is checked here rather than trusted: that there is a +## choice owing, that the index names an option the server itself offered, and +## that the player is standing at the NPC. The index is the only thing the +## client contributes, and it selects from a list the client did not write. +func on_choose_upgrade(peer_id: int, index: int) -> void: + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + var character_id: String = peer_characters.get(peer_id, "") + if account == AuthProvider.NO_ACCOUNT or character_id.is_empty(): + Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, + "not signed in") + return + var here := instance_of(peer_id) + if here == null or here.kind != Protocol.InstanceKind.LOBBY: + Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB, + "upgrades are chosen in the hub") + return + var p: SimPlayer = here.world.players.get(peer_id) + if p == null or not here.world.at_upgrade_npc(p.pos): + Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AT_THE_NPC, + "stand at the quartermaster to spend a level") + return + var taken := store.take_upgrade(account, character_id, index) + if taken.is_empty(): + Net.send_select_result(peer_id, Protocol.SelectResult.NOTHING_TO_CHOOSE, + "nothing to choose") + _send_upgrades(peer_id) + return + # Rebuild the player's numbers from the new list. adopt() does exactly this + # and nothing else that matters here, so it stays the single place where a + # character's record becomes a player's stats. + var c := store.get_character(account, character_id) + if c != null: + p.adopt(c) + GameLog.info("server", "peer %d took upgrade '%s'" % [peer_id, taken]) + Net.send_select_result(peer_id, Protocol.SelectResult.OK, "") + _send_upgrades(peer_id) + _send_characters(peer_id) + + +func _send_upgrades(peer_id: int) -> void: + var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) + var character_id: String = peer_characters.get(peer_id, "") + if account == AuthProvider.NO_ACCOUNT or character_id.is_empty(): + return + var c := store.get_character(account, character_id) + if c == null: + return + Net.send_upgrades(peer_id, NetCodec.encode_upgrade_state( + c.pending_choices, c.offer, c.upgrades)) + + func on_input(peer_id: int, data: PackedByteArray) -> void: var inst := instance_of(peer_id) if inst == null: @@ -332,7 +386,8 @@ func _place(peer_id: int, inst: Instance) -> void: Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick, String(inst.boss_id), inst.world.spawn_point, inst.world.map.width, inst.world.map.height, - NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id)) + NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id), + inst.world.upgrade_npc) # Seed the area around the spawn before anything else, so the player is not # briefly standing in an unrendered void on arrival. _stream_map(peer_id, inst) @@ -489,6 +544,10 @@ func _grant_xp(peer_id: int, amount: int) -> void: mine.total_xp = earned.total_xp if levels <= 0: return + # One choice per level, banked rather than offered immediately: the NPC is + # in the hub and the level was earned in a dungeon, so they have to queue or + # they would be lost. + store.grant_choices(account, character_id, levels) # A level raises max health immediately, and heals by the amount gained -- # a level-up mid-fight should feel like relief, not like a bar that grew # further away from full. @@ -501,8 +560,10 @@ func _grant_xp(peer_id: int, amount: int) -> void: p.level = c.level p.max_hp = c.max_hp() p.hp = mini(p.hp + (p.max_hp - before), p.max_hp) - GameLog.info("server", "peer %d reached level %d" % [peer_id, c.level]) + GameLog.info("server", "peer %d reached level %d (%d choice(s) pending)" + % [peer_id, c.level, c.pending_choices]) _send_characters(peer_id) + _send_upgrades(peer_id) ## Death is permanent. The character is retired -- kept for archival, never diff --git a/src/sim/bullet_pool.gd b/src/sim/bullet_pool.gd index 111ab3a..a28d99d 100644 --- a/src/sim/bullet_pool.gd +++ b/src/sim/bullet_pool.gd @@ -20,6 +20,19 @@ var team := PackedByteArray() var kind := PackedByteArray() var alive := PackedByteArray() +# --- Player upgrade riders, server-only ------------------------------------- +# Carried on the bullet rather than looked up from the shooter, because a shot +# already in flight keeps the properties it was fired with -- levelling up +# mid-flight must not retroactively poison a bullet that left before you took +# Poison. All three stay 0 on a client replica, which never resolves a hit and +# so has no use for them. +## Times this shot may still split on hitting something. +var split := PackedInt32Array() +## Fraction of this shot's damage to apply again as poison. +var poison := PackedFloat32Array() +## Chance per tick that this shot deletes an enemy projectile it overlaps. +var erase := PackedFloat32Array() + ## The geometry bullets die against. Set by the owning SimWorld; identical on ## server and client, which is what keeps the replica in step. var map: MapGrid = null @@ -58,6 +71,9 @@ func _init() -> void: team.resize(n) kind.resize(n) alive.resize(n) + split.resize(n) + poison.resize(n) + erase.resize(n) func clear() -> void: @@ -97,6 +113,11 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int, team[slot] = bullet_team kind[slot] = bullet_kind alive[slot] = 1 + # Cleared on every spawn, so a reused slot never inherits the last + # occupant's upgrades. + split[slot] = 0 + poison[slot] = 0.0 + erase[slot] = 0.0 if forced_uid != 0: uid[slot] = forced_uid else: @@ -107,6 +128,17 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int, return slot +## Attach a shooter's upgrade riders. Called immediately after spawn() by the +## server; never by the replica. +func set_mods(slot: int, split_charges: int, poison_fraction: float, + erase_chance: float) -> void: + if slot < 0: + return + split[slot] = split_charges + poison[slot] = poison_fraction + erase[slot] = erase_chance + + func clear_spawn_log() -> void: spawn_log.clear() wall_kill_log.clear() diff --git a/src/sim/map_gen.gd b/src/sim/map_gen.gd index 3721432..3ed43ac 100644 --- a/src/sim/map_gen.gd +++ b/src/sim/map_gen.gd @@ -52,12 +52,17 @@ static func _build_lobby() -> Dictionary: if not markers["T"].is_empty(): var m: Vector2i = markers["T"][0] target = grid.tile_centre(m.x, m.y) + var npc := grid.tile_centre(size.x * 3 / 4, size.y / 2) + if not markers["U"].is_empty(): + var m: Vector2i = markers["U"][0] + npc = grid.tile_centre(m.x, m.y) return { "grid": grid, "rooms": [] as Array[Rect2i], "spawn": spawn, "portals": portals, "dummy": target, + "npc": npc, "boss_pos": Vector2.ZERO, "boss_room": Rect2i(), } @@ -148,6 +153,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary: "spawn": spawn, "portals": [] as Array[SimPortal], "dummy": Vector2.ZERO, + "npc": Vector2.ZERO, "boss_pos": boss_pos, "boss_room": boss_room, } diff --git a/src/sim/player_stats.gd b/src/sim/player_stats.gd new file mode 100644 index 0000000..9673d51 --- /dev/null +++ b/src/sim/player_stats.gd @@ -0,0 +1,78 @@ +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 diff --git a/src/sim/player_stats.gd.uid b/src/sim/player_stats.gd.uid new file mode 100644 index 0000000..bbc1c9c --- /dev/null +++ b/src/sim/player_stats.gd.uid @@ -0,0 +1 @@ +uid://57ld2euau2ua diff --git a/src/sim/poison_track.gd b/src/sim/poison_track.gd new file mode 100644 index 0000000..501f34c --- /dev/null +++ b/src/sim/poison_track.gd @@ -0,0 +1,70 @@ +class_name PoisonTrack +extends RefCounted +## Damage over time on one actor, from any number of independent doses. +## +## The user chose independent stacks: every poisoned hit starts its own full +## window rather than refreshing a shared one, so a fast weapon can have dozens +## running at once. That could have meant walking dozens of timers per enemy per +## tick, and it does not, because every dose lasts exactly the same number of +## ticks: +## +## - the total damage per tick is one running float, [member rate]; +## - doses therefore expire in the order they were added, so the pending +## expiries are a plain FIFO and only the front of it is ever examined. +## +## The result is O(1) per tick per actor no matter how many doses are live, +## which is what makes "43 concurrent stacks" a non-event rather than a budget. + +## Damage per tick, summed over every live dose. +var rate: float = 0.0 + +## Fractional damage carried between ticks. A single dose is far under one hit +## point per tick, so without this it would round to nothing forever. +var _carry: float = 0.0 +var _expiry := PackedInt32Array() +var _rate := PackedFloat32Array() +var _head: int = 0 + + +## Add a dose worth [param total_damage] spread over the poison window. +func add(total_damage: float, at_tick: int) -> void: + if total_damage <= 0.0: + return + var per_tick := total_damage / float(SimConfig.POISON_DURATION_TICKS) + _expiry.append(at_tick + SimConfig.POISON_DURATION_TICKS) + _rate.append(per_tick) + rate += per_tick + + +## Retire finished doses and return whole hit points to apply this tick. +func step(at_tick: int) -> int: + while _head < _expiry.size() and _expiry[_head] <= at_tick: + rate -= _rate[_head] + _head += 1 + if _head >= _expiry.size(): + # Everything expired. Reset rather than subtract to zero: the running + # sum accumulates float error, and "nothing is poisoned" has to mean + # exactly nothing. + _expiry.clear() + _rate.clear() + _head = 0 + rate = 0.0 + _carry = 0.0 + return 0 + if rate <= 0.0: + return 0 + _carry += rate + if _carry < 1.0: + return 0 + var whole := int(_carry) + _carry -= float(whole) + return whole + + +func active() -> bool: + return rate > 0.0 + + +## Live doses, for tests and the debug overlay. +func dose_count() -> int: + return _expiry.size() - _head diff --git a/src/sim/poison_track.gd.uid b/src/sim/poison_track.gd.uid new file mode 100644 index 0000000..b1a1d11 --- /dev/null +++ b/src/sim/poison_track.gd.uid @@ -0,0 +1 @@ +uid://vuiinq8qng3i diff --git a/src/sim/sim_boss.gd b/src/sim/sim_boss.gd index b1e7c45..ea80f4b 100644 --- a/src/sim/sim_boss.gd +++ b/src/sim/sim_boss.gd @@ -15,6 +15,14 @@ var phase_tick: int = 0 ## player can always disengage by walking out -- which is the trade for the ## boss room having no door that locks. var room := Rect2() +## Poison doses ticking on the boss. Lazy for the same reason enemies' are. +var poison: PoisonTrack = null + + +func poison_track() -> PoisonTrack: + if poison == null: + poison = PoisonTrack.new() + return poison func hp_fraction() -> float: diff --git a/src/sim/sim_enemy.gd b/src/sim/sim_enemy.gd index 6e829cf..09d8e9b 100644 --- a/src/sim/sim_enemy.gd +++ b/src/sim/sim_enemy.gd @@ -19,6 +19,15 @@ var target_dir := Vector2.ZERO ## sight. Refreshed by the world every tick; null means idle, which is the ## normal state for most of a dungeon. var target: SimPlayer = null +## Poison doses ticking on this enemy. Built lazily: most enemies in a dungeon +## are never poisoned, and most runs never see the upgrade at all. +var poison: PoisonTrack = null + + +func poison_track() -> PoisonTrack: + if poison == null: + poison = PoisonTrack.new() + return poison func hp_fraction() -> float: diff --git a/src/sim/sim_player.gd b/src/sim/sim_player.gd index 9631449..9108cea 100644 --- a/src/sim/sim_player.gd +++ b/src/sim/sim_player.gd @@ -42,6 +42,10 @@ var inventory: Array[StringName] = [] var prev_buttons: int = 0 var prev_slot: int = -1 +## Combat numbers derived from the character's upgrades. Rebuilt whenever the +## upgrade list changes, never stored on disk -- see [PlayerStats]. +var stats := PlayerStats.new() + ## The peer's connection dropped, but the player is deliberately still in the ## world. Held here rather than deleted so a disconnect cannot be used to dodge ## a dangerous moment: a linkdead player keeps channelling the escape (and stays @@ -86,7 +90,7 @@ func can_fire() -> bool: ## SimConfig.SPAWN_GRACE_TICKS for a dungeon. func reset_for_instance(spawn: Vector2, grace: int = 0) -> void: pos = spawn - max_hp = Progression.max_hp_for_level(level) + recompute_max_hp() hp = max_hp alive = true spawn_grace = grace @@ -120,11 +124,23 @@ func adopt(c: Character) -> void: level = c.level total_xp = c.total_xp colour = c.colour - max_hp = c.max_hp() + stats = PlayerStats.build(c.upgrades) + recompute_max_hp() hp = mini(hp, max_hp) set_inventory(c.inventory) +## Maximum health is the level's value scaled by whatever the upgrades do to it +## -- Glass Cannon halves it. Multiplying the LEVELLED value rather than the +## base is what keeps that trade a real price at level 15 instead of a rounding +## error. Floored at 1: an upgrade must never be able to make you unkillable by +## making you already dead. +func recompute_max_hp() -> void: + max_hp = maxi(1, roundi(float(Progression.max_hp_for_level(level)) + * stats.max_hp_mult)) + hp = mini(hp, max_hp) + + ## Replace the whole inventory, padding or trimming to the configured slot ## count so a save file written when the game had a different number of slots ## still loads into a valid player. diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index bf49e85..8b392c1 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -37,6 +37,11 @@ var map: MapGrid = null ## A list rather than a single position because which dungeon an entrance opens ## is the whole reason there is more than one. var portals: Array[SimPortal] = [] +## Where the hub's upgrade NPC stands. Only meaningful when +## [member has_upgrade_npc] is true -- the map is centred on the origin, so +## Vector2.ZERO is a real position and cannot double as "there isn't one". +var upgrade_npc := Vector2.ZERO +var has_upgrade_npc: bool = false var spawn_point := Vector2(0.0, 240.0) ## Arrival protection granted to players entering this world. 0 in the hub, ## SimConfig.SPAWN_GRACE_TICKS in a dungeon. @@ -131,6 +136,14 @@ func portal_at(at: Vector2) -> SimPortal: return best +## Whether [param at] is close enough to spend a level-up. Checked on the +## server for the same reason the portal is: where a player is standing is the +## one thing a modified client cannot fake. +func at_upgrade_npc(at: Vector2) -> bool: + return has_upgrade_npc \ + and at.distance_to(upgrade_npc) <= SimConfig.UPGRADE_NPC_RADIUS + + func alive_player_count() -> int: var n := 0 for p in players.values(): @@ -186,6 +199,7 @@ func step() -> void: _step_boss() pool.step() _resolve_bullet_hits() + _step_poison() _emit_wall_kill_events() _emit_spawn_events() else: @@ -276,18 +290,44 @@ func _button_edge(p: SimPlayer, frame: InputFrame) -> int: return edge +## One trigger pull. How many bullets that is, and what they carry, comes +## entirely from the shooter's [PlayerStats] -- there is no per-upgrade branch +## here, which is what lets a new upgrade be a table entry in [Upgrades]. func _fire_player_shot(p: SimPlayer) -> void: - p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN + p.fire_cooldown = p.stats.fire_cooldown events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id}) - var dir := Vector2.RIGHT.rotated(p.aim) - pool.spawn( - p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET, - dir * SimConfig.PLAYER_BULLET_SPEED, + var forward := Vector2.RIGHT.rotated(p.aim) + var muzzle := p.pos + forward * SimConfig.PLAYER_MUZZLE_OFFSET + + # The aimed shot plus Spread's cone, alternating sides so an odd count still + # comes out symmetric about the aim. + _spawn_player_bullet(p, muzzle, forward) + for i in p.stats.side_shots: + var step := float(i / 2 + 1) * SimConfig.SPREAD_STEP_DEG + var side_sign := 1.0 if i % 2 == 0 else -1.0 + _spawn_player_bullet(p, muzzle, + forward.rotated(deg_to_rad(step * side_sign))) + + # Doubleshot's extras travel parallel rather than fanned, so they are an + # offset at the muzzle and not an angle. + for i in p.stats.parallel_shots: + var gap := float(i / 2 + 1) * SimConfig.PARALLEL_OFFSET + var lateral_sign := 1.0 if i % 2 == 0 else -1.0 + _spawn_player_bullet(p, muzzle + forward.orthogonal() * gap * lateral_sign, + forward) + + +func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void: + var slot := pool.spawn( + at, + dir * p.stats.bullet_speed, SimConfig.PLAYER_BULLET_RADIUS, SimConfig.PLAYER_BULLET_LIFETIME, - SimConfig.PLAYER_BULLET_DAMAGE, + p.stats.damage, SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT) + pool.set_mods(slot, p.stats.split_charges, p.stats.poison_fraction, + p.stats.erase_chance) # --- Items and loot --------------------------------------------------------- @@ -592,7 +632,12 @@ func _resolve_bullet_hits() -> void: var consumed := false if boss != null and boss.alive \ and Movement.circles_overlap(bp, br, boss.pos, boss.def.radius): - _damage_boss(pool.damage[i]) + # Poison is taken from the damage the boss ACTUALLY took, so a + # phase that takes 30% extra is poisoned 30% harder too. + var applied := _damage_boss(pool.damage[i]) + if pool.poison[i] > 0.0: + boss.poison_track().add(float(applied) * pool.poison[i], tick) + _split_shot(i, boss.pos, boss.def.radius) consumed = true if not consumed: for e in enemies.values(): @@ -600,10 +645,82 @@ func _resolve_bullet_hits() -> void: continue if Movement.circles_overlap(bp, br, e.pos, e.def.radius): _damage_enemy(e, pool.damage[i]) + if pool.poison[i] > 0.0: + e.poison_track().add( + float(pool.damage[i]) * pool.poison[i], tick) + _split_shot(i, e.pos, e.def.radius) consumed = true break if consumed: _kill_bullet(i) + elif pool.erase[i] > 0.0: + _try_erase(i) + + +## Split Shot: two children of the shot that just landed, leaving the target at +## 45 degrees to either side. +## +## They are born just PAST the target rather than on it. A child spawned inside +## the thing that 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. +## +## Children inherit the remaining lifetime rather than a fresh one, so splitting +## cannot extend a shot's reach indefinitely; a split at the end of a shot's +## life produces two short-lived children, which is the conservative reading of +## "two of the same bullet". +func _split_shot(slot: int, from: Vector2, target_radius: float) -> void: + if pool.split[slot] <= 0: + return + var v: Vector2 = pool.vel[slot] + var speed := v.length() + if speed < 0.001: + return + var charges := pool.split[slot] - 1 + var clearance := target_radius + pool.radius[slot] + 2.0 + for side_sign in [1.0, -1.0]: + var dir := (v / speed).rotated(deg_to_rad(SimConfig.SPLIT_ANGLE_DEG) * side_sign) + var child := pool.spawn(from + dir * clearance, dir * speed, + pool.radius[slot], pool.life[slot], pool.damage[slot], + pool.team[slot], pool.kind[slot]) + pool.set_mods(child, charges, pool.poison[slot], pool.erase[slot]) + + +## Eraser: at most ONE roll per shot per tick, against the first enemy +## projectile it is overlapping. +## +## Rolling once per overlapping pair would multiply the stated 1% by however +## many bullets happened to occupy the same place, which in a boss ring is a +## lot. One opportunity per tick, taken or not, keeps the number the player was +## promised close to the number they get. +## +## The scan is O(bullets) per erasing shot, and runs only for shots that carry +## the upgrade -- which is why it is worth nothing until a legendary is drawn. +func _try_erase(slot: int) -> void: + var p: Vector2 = pool.pos[slot] + var r: float = pool.radius[slot] + for j in pool.high_water: + if pool.alive[j] == 0 or pool.team[j] != SimConfig.TEAM_ENEMY: + continue + if not Movement.circles_overlap(p, r, pool.pos[j], pool.radius[j]): + continue + if rng.randf() < pool.erase[slot]: + _kill_bullet(j) + return + + +## One tick of every live poison dose. Cheap regardless of how many are running +## -- see [PoisonTrack] for why. +func _step_poison() -> void: + for e in enemies.values(): + if not e.alive or e.poison == null: + continue + var dealt: int = e.poison.step(tick) + if dealt > 0: + _damage_enemy(e, dealt, true) + if boss != null and boss.alive and boss.poison != null: + var on_boss: int = boss.poison.step(tick) + if on_boss > 0: + _damage_boss(on_boss, true, false) ## Bullets removed early must be announced -- clients cannot derive a hit. @@ -624,9 +741,14 @@ func _damage_player(p: SimPlayer, amount: int) -> void: events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id}) -func _damage_enemy(e: SimEnemy, amount: int) -> void: +## [param silent] suppresses the ENEMY_HIT event. Poison ticks use it: they +## land many times a second on the reliable channel, and the client learns hp +## from the snapshot anyway. Death is still announced either way, because that +## is what the experience award is keyed on. +func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void: e.hp = maxi(e.hp - amount, 0) - events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp}) + if not silent: + events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp}) if e.hp <= 0: e.alive = false _drop_loot(e.def.loot, e.pos) @@ -635,16 +757,23 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void: events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)}) -func _damage_boss(amount: int) -> void: +## Returns the damage actually applied, after the phase's armour multiplier, so +## the caller can derive poison from the same number. +## +## [param scale] is false for poison ticks, whose damage was already scaled when +## the dose was applied -- scaling again would compound the multiplier. +func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int: var phase := boss.current_phase() - var mult := 1.0 if phase == null else phase.damage_taken_mult + var mult := 1.0 if phase == null or not scale else phase.damage_taken_mult var applied := maxi(1, roundi(float(amount) * mult)) boss.hp = maxi(boss.hp - applied, 0) - events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp}) + if not silent: + events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp}) if boss.hp <= 0: boss.alive = false _drop_loot(boss.def.loot, boss.pos) events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)}) + return applied ## Bullets that died against geometry. A client is only streamed the map near diff --git a/src/ui/hud.gd b/src/ui/hud.gd index d0d93ce..fcd4d6d 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -147,6 +147,7 @@ func _draw_hud() -> void: _draw_roster() _draw_inventory() _draw_pickup_prompt() + _draw_level_up_notice() if not client.my_alive: # Centred on the canvas, which is only correct because _canvas actually @@ -161,6 +162,23 @@ func _draw_hud() -> void: Color(1.0, 0.2, 0.25, 0.18 * _hit_flash)) +## Unspent level-ups. Shown wherever you are, because the choice is banked in a +## dungeon and spent in the hub -- if it only appeared next to the NPC, a player +## would have to already know to go and look. +func _draw_level_up_notice() -> void: + if client.upgrades_pending <= 0: + return + var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004) + var where := " — see the quartermaster in the hub" \ + if client.instance_kind != Protocol.InstanceKind.LOBBY else "" + _canvas.draw_string(ThemeDB.fallback_font, + Vector2(MARGIN, MARGIN + 74.0), + "%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending, + "" if client.upgrades_pending == 1 else "S", where], + HORIZONTAL_ALIGNMENT_LEFT, -1, 14, + Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse)) + + ## Four slots, always on screen. Deliberately not a panel you open: an ## inventory you have to stop and read is a menu, and a menu is a death in a ## game where the floor is bullets. Everything drawn here comes from the diff --git a/src/ui/upgrade_screen.gd b/src/ui/upgrade_screen.gd new file mode 100644 index 0000000..64af3d3 --- /dev/null +++ b/src/ui/upgrade_screen.gd @@ -0,0 +1,206 @@ +extends CanvasLayer +## The quartermaster: spend a level-up, and review what you have taken. +## +## The three options are the SERVER's, held on the character until one is +## spent -- closing and reopening this screen shows the same three, because +## otherwise it would be a reroll button and everyone would press it until a +## legendary appeared. +## +## The brief asked for a separate screen listing upgrades already taken. It is +## the lower half of this one instead: the two are read together (what do I +## have, what should I add) and splitting them would mean closing one panel to +## answer a question raised by the other. + +signal choose_requested(index: int) +signal closed + +const RARITY_COLOURS := { + UpgradeDef.Rarity.COMMON: Color(0.72, 0.76, 0.84), + UpgradeDef.Rarity.UNCOMMON: Color(0.55, 0.85, 0.6), + UpgradeDef.Rarity.RARE: Color(0.55, 0.7, 1.0), + UpgradeDef.Rarity.LEGENDARY: Color(1.0, 0.72, 0.3), +} + +var _title: Label +var _cards: HBoxContainer +var _taken: Label +var _summary: Label +var _status: Label + + +func _ready() -> void: + layer = 28 + visible = false + var root := Control.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(root) + + var scrim := ColorRect.new() + scrim.color = Color(0.03, 0.03, 0.06, 0.9) + scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(scrim) + + var centre := CenterContainer.new() + centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(centre) + + var panel := VBoxContainer.new() + panel.custom_minimum_size = Vector2(760.0, 0.0) + panel.add_theme_constant_override("separation", 10) + centre.add_child(panel) + + _title = Label.new() + _title.add_theme_font_size_override("font_size", 26) + panel.add_child(_title) + + _cards = HBoxContainer.new() + _cards.add_theme_constant_override("separation", 10) + panel.add_child(_cards) + + _status = Label.new() + _status.add_theme_color_override("font_color", Color(1.0, 0.6, 0.5)) + panel.add_child(_status) + + panel.add_child(_rule()) + + var taken_title := Label.new() + taken_title.text = "ALREADY TAKEN" + taken_title.add_theme_font_size_override("font_size", 15) + taken_title.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78)) + panel.add_child(taken_title) + + _taken = Label.new() + _taken.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _taken.custom_minimum_size = Vector2(740.0, 0.0) + panel.add_child(_taken) + + _summary = Label.new() + _summary.add_theme_color_override("font_color", Color(0.65, 0.72, 0.85)) + _summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _summary.custom_minimum_size = Vector2(740.0, 0.0) + panel.add_child(_summary) + + var close := Button.new() + close.text = "Close (E)" + close.custom_minimum_size = Vector2(160.0, 34.0) + close.pressed.connect(func() -> void: closed.emit()) + panel.add_child(close) + + +func _rule() -> Control: + var line := ColorRect.new() + line.color = Color(0.2, 0.22, 0.3) + line.custom_minimum_size = Vector2(0.0, 1.0) + return line + + +func set_status(text: String) -> void: + _status.text = text + + +## Rebuild from the server's numbers. Called on every upgrade change, so the +## screen can never show an offer that has already been spent. +func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void: + _status.text = "" + if pending > 0: + _title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending + else: + _title.text = "QUARTERMASTER — nothing to spend" + for child in _cards.get_children(): + child.queue_free() + if pending > 0: + for i in offer.size(): + _cards.add_child(_make_card(i, offer[i])) + _taken.text = _taken_text(taken) + _summary.text = _summary_text(taken) + + +func _make_card(index: int, id: StringName) -> Control: + var def := Upgrades.get_def(id) + var card := VBoxContainer.new() + card.custom_minimum_size = Vector2(240.0, 0.0) + card.add_theme_constant_override("separation", 6) + if def == null: + return card + var tint: Color = RARITY_COLOURS.get(def.rarity, Color.WHITE) + + var name_label := Label.new() + name_label.text = def.display_name + name_label.add_theme_font_size_override("font_size", 18) + name_label.add_theme_color_override("font_color", tint) + card.add_child(name_label) + + var rarity := Label.new() + rarity.text = UpgradeDef.rarity_name(def.rarity).to_upper() + rarity.add_theme_font_size_override("font_size", 11) + rarity.add_theme_color_override("font_color", Color(tint, 0.7)) + card.add_child(rarity) + + var body := Label.new() + body.text = def.description + body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + body.custom_minimum_size = Vector2(230.0, 96.0) + body.add_theme_font_size_override("font_size", 13) + card.add_child(body) + + # Shown on every card, because it applies to every card. The brief asks for + # the choice screen to show this buff as well as the upgrade's own effects, + # and a player comparing three cards should not have to remember it. + var bonus := Label.new() + bonus.text = "+%d%% damage (every upgrade)" % roundi( + SimConfig.UPGRADE_DAMAGE_BONUS * 100.0) + bonus.add_theme_font_size_override("font_size", 12) + bonus.add_theme_color_override("font_color", Color(0.55, 0.8, 0.6)) + card.add_child(bonus) + + var take := Button.new() + take.text = "Take" + take.custom_minimum_size = Vector2(0.0, 34.0) + take.pressed.connect(func() -> void: choose_requested.emit(index)) + card.add_child(take) + return card + + +## Counted rather than listed one per line: "Split Shot x3" is the number that +## matters, and a level 15 character has fourteen of these. +func _taken_text(taken: Array[StringName]) -> String: + if taken.is_empty(): + return "nothing yet" + var counts := {} + var order: Array[StringName] = [] + for id in taken: + if not counts.has(id): + counts[id] = 0 + order.append(id) + counts[id] += 1 + var parts: Array[String] = [] + for id in order: + var def := Upgrades.get_def(id) + var name_text := def.display_name if def != null else String(id) + parts.append("%s x%d" % [name_text, int(counts[id])] if int(counts[id]) > 1 + else name_text) + return " ".join(parts) + + +## What the upgrades actually add up to. Built through PlayerStats, the same +## class the server fires with, so this cannot drift from the real numbers. +func _summary_text(taken: Array[StringName]) -> String: + var s := PlayerStats.build(taken) + var shots := 1 + s.side_shots + s.parallel_shots + var per_second := float(SimConfig.TICK_RATE) / float(s.fire_cooldown) + var parts: Array[String] = [ + "%d damage per shot" % s.damage, + "%.1f shots/sec" % per_second, + "%d projectile%s per shot" % [shots, "" if shots == 1 else "s"], + "%d bullet speed%s" % [roundi(s.bullet_speed), + " (capped)" if s.speed_capped else ""], + ] + if s.max_hp_mult != 1.0: + parts.append("%d%% max health" % roundi(s.max_hp_mult * 100.0)) + if s.split_charges > 0: + parts.append("splits %dx" % s.split_charges) + if s.poison_fraction > 0.0: + parts.append("+%d%% as poison" % roundi(s.poison_fraction * 100.0)) + if s.erase_chance > 0.0: + parts.append("%.1f%% erase" % (s.erase_chance * 100.0)) + return " · ".join(parts) diff --git a/src/ui/upgrade_screen.gd.uid b/src/ui/upgrade_screen.gd.uid new file mode 100644 index 0000000..59989fb --- /dev/null +++ b/src/ui/upgrade_screen.gd.uid @@ -0,0 +1 @@ +uid://cy32nmdfkt57i diff --git a/src/view/art.gd b/src/view/art.gd index fc8872c..0b6a3ba 100644 --- a/src/view/art.gd +++ b/src/view/art.gd @@ -98,6 +98,9 @@ const ENEMY_IDLE: Array[Rect2] = [ Rect2(288, 336, 16, 16), # red flask -> practice target ] const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon +## The hub's quartermaster. A different character strip from the knight the +## player wears, so an NPC never reads as another player standing still. +const NPC_IDLE := Rect2(128, 36, 16, 28) ## ItemDef.visual -> atlas rect. Order matches Items.ORDER: health potion, ## Warden's ration. Both come from the tileset's flask row -- there is no food diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index 8895c0d..570a5f4 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -8,11 +8,17 @@ extends Node2D @onready var menu: CanvasLayer = $GameMenu @onready var sfx: Node = $Sfx @onready var characters: CanvasLayer = $CharacterSelect +@onready var upgrades: CanvasLayer = $UpgradeScreen var _bound: ClientRuntime = null ## Opened deliberately from the menu, as opposed to forced open by having no ## character to play. var _roster_open: bool = false +## The quartermaster panel. Opened by walking to the NPC and pressing interact, +## and closed the moment you walk away -- the server refuses a choice made from +## anywhere else, so leaving the panel open at a distance would only offer a +## button that gets rejected. +var _upgrades_open: bool = false func _ready() -> void: @@ -27,6 +33,8 @@ func _ready() -> void: Net.select_character(id)) characters.create_requested.connect(func(n: String) -> void: Net.create_character(n)) characters.closed.connect(func() -> void: _roster_open = false) + upgrades.closed.connect(func() -> void: _upgrades_open = false) + upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i)) var _screen_centre := Vector2.ZERO @@ -66,7 +74,11 @@ func _process(_delta: float) -> void: _bound.item_used.connect(func(_i: StringName) -> void: sfx.play(Art.SFX_SHOOT, -10.0)) _bound.characters_changed.connect(_refresh_characters) - _bound.select_failed.connect(func(why: String) -> void: characters.set_status(why)) + _bound.upgrades_changed.connect(_refresh_upgrades) + _refresh_upgrades() + _bound.select_failed.connect(func(why: String) -> void: + characters.set_status(why) + upgrades.set_status(why)) _refresh_characters() # The roster screen is shown exactly when there is nothing to play: first # login, or after the last living character died. @@ -79,11 +91,33 @@ func _process(_delta: float) -> void: _roster_open = false characters.visible = forced or _roster_open characters.set_dismissible(not forced) + if _bound != null and _upgrades_open and not _bound.at_upgrade_npc(): + _upgrades_open = false + upgrades.visible = _upgrades_open _follow_camera() menu.set_in_dungeon(_bound != null and _bound.instance_kind == Protocol.InstanceKind.DUNGEON) +func _unhandled_input(event: InputEvent) -> void: + if not event.is_action_pressed("interact"): + return + if _bound == null or not _bound.at_upgrade_npc(): + return + # Interact is also pick-up. Loot wins, exactly as it does on the server, so + # the key never does one thing here and another there. + if not _bound.loot_in_reach().is_empty(): + return + _upgrades_open = not _upgrades_open + _refresh_upgrades() + + +func _refresh_upgrades() -> void: + if _bound != null: + upgrades.refresh(_bound.upgrades_pending, _bound.upgrade_offer, + _bound.upgrades_taken) + + ## Routed through the same held-escape channel the F key uses, rather than a ## direct "teleport me" message -- the server has no such message, and adding ## one would hand clients an instant, uninterruptible exit. diff --git a/src/view/world_view.gd b/src/view/world_view.gd index df4865a..72c6816 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -55,6 +55,7 @@ func _draw() -> void: return _draw_terrain() _draw_portals() + _draw_upgrade_npc() for l in client.ground_loot(): if _visible(l["pos"]): _draw_loot(l) @@ -185,6 +186,35 @@ func _draw_portals() -> void: _draw_portal_label(at, def) +## The quartermaster. Drawn with a prompt rather than left to be discovered: +## the whole upgrade system is behind one unmarked figure in a large room. +func _draw_upgrade_npc() -> void: + if client.instance_kind != Protocol.InstanceKind.LOBBY: + return + var at := client.upgrade_npc + if not _visible(at): + return + var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3)) + _draw_sprite(Art.TILESET, src, at, Color.WHITE, false, Art.PLAYER_ANCHOR) + var waiting := client.upgrades_pending + var tint := Color(1.0, 0.85, 0.4) if waiting > 0 else Color(0.65, 0.7, 0.82) + var label := "QUARTERMASTER" + if waiting > 0: + label = "QUARTERMASTER — %d level-up%s to spend" % [ + waiting, "" if waiting == 1 else "s"] + draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 34.0), label, + HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint) + if client.at_upgrade_npc(): + draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0), + "E talk", HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13, + Color(0.8, 0.85, 0.95)) + # A pending choice pulses, so it is visible from across the hub. + if waiting > 0: + var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.005) + draw_arc(at, 26.0 + 4.0 * pulse, 0.0, TAU, 32, + Color(1.0, 0.85, 0.4, 0.25 + 0.35 * pulse), 2.0) + + func _draw_portal_label(at: Vector2, def: DungeonDef) -> void: var font := ThemeDB.fallback_font var top := at - Vector2(0.0, SimConfig.PORTAL_RADIUS + 26.0) diff --git a/tests/unit/test_bullet_pool.gd b/tests/unit/test_bullet_pool.gd index 34bb7c2..e1624a8 100644 --- a/tests/unit/test_bullet_pool.gd +++ b/tests/unit/test_bullet_pool.gd @@ -84,15 +84,33 @@ func test_bullets_stopped_by_a_wall_are_logged_for_announcement() -> void: assert_true(pool.wall_kill_log.has(id), "wall deaths must be announceable") -## Wall collision samples the bullet's position once per tick, so a bullet that -## travels more than one tile per tick can step straight over a wall. Nothing in -## the game comes close today, but a future "fast projectile" upgrade could, and -## it would look like walls randomly failing. +## Wall collision samples a position once per tick, so a bullet that covers +## more than a tile in that tick passes through solid geometry. The ceiling +## exists because upgrades multiply bullet speed and two Snipers would ask for +## 2480 u/s -- above the threshold, and silently, since a tunnelling bullet +## looks like a bullet. func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void: var limit := MapGrid.TILE / SimConfig.TICK_DELTA - assert_lt(SimConfig.PLAYER_BULLET_SPEED * 2.0, limit, - "even at the doubled speed an upgrade could grant, a bullet must not " + - "cross a whole tile in one tick or it will tunnel through walls") + assert_lt(SimConfig.MAX_BULLET_SPEED, limit, + "the speed ceiling itself has to be under a tile per tick") + var stacked: Array[StringName] = [] + for _i in 6: + stacked.append(Upgrades.SNIPER) + assert_lt(PlayerStats.build(stacked).bullet_speed, limit, + "%d stacked Snipers must not produce a tunnelling bullet" % stacked.size()) + + +func test_every_enemy_bullet_in_the_game_is_also_below_it() -> void: + var limit := MapGrid.TILE / SimConfig.TICK_DELTA + var emitters: Array[BulletEmitter] = [] + for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]: + emitters.append_array(Content.enemy(id).emitters) + for phase in Content.warden().phases: + emitters.append_array(phase.emitters) + for e in emitters: + # Accelerating bullets reach their top speed at the end of their life. + var top: float = e.speed + maxf(e.accel, 0.0) * float(e.lifetime) * SimConfig.TICK_DELTA + assert_lt(top, limit, "an emitter reaches %.0f u/s" % top) func test_a_pool_with_no_map_does_not_cull() -> void: diff --git a/tests/unit/test_character_store.gd b/tests/unit/test_character_store.gd index 6d09a8b..7fa8477 100644 --- a/tests/unit/test_character_store.gd +++ b/tests/unit/test_character_store.gd @@ -210,3 +210,105 @@ func test_setting_an_inventory_on_a_missing_character_is_harmless() -> void: var nothing: Array[StringName] = [Items.HEALTH_POTION] store.set_inventory(ACC, "no-such-id", nothing) assert_eq(store.characters_for(ACC).size(), 0) + + +# --- Upgrades --------------------------------------------------------------- + +func test_a_level_banks_a_choice_and_puts_three_on_the_table() -> void: + var c := store.create_character(ACC, "Riser") + store.grant_choices(ACC, c.id, 1) + assert_eq(c.pending_choices, 1) + assert_eq(c.offer.size(), SimConfig.UPGRADE_CHOICES) + + +## The offer is rolled once and held. If it regenerated per request, closing and +## reopening the screen would be a free reroll until a legendary appeared. +func test_the_offer_is_stable_until_something_is_taken() -> void: + var c := store.create_character(ACC, "Picky") + store.grant_choices(ACC, c.id, 1) + var first := c.offer.duplicate() + store.grant_choices(ACC, c.id, 1) + assert_eq(c.offer, first, "banking another level must not reroll the table") + + +## Two levels in one run owe two choices. Losing one for doing well is a +## punishment nobody would guess at. +func test_choices_queue() -> void: + var c := store.create_character(ACC, "Fast") + store.grant_choices(ACC, c.id, 3) + assert_eq(c.pending_choices, 3) + store.take_upgrade(ACC, c.id, 0) + assert_eq(c.pending_choices, 2) + assert_eq(c.offer.size(), SimConfig.UPGRADE_CHOICES, "and the next three appear") + + +func test_taking_an_upgrade_records_it_and_rerolls_the_table() -> void: + var c := store.create_character(ACC, "Taker") + store.grant_choices(ACC, c.id, 1) + var wanted: StringName = c.offer[1] + var taken := store.take_upgrade(ACC, c.id, 1) + assert_eq(taken, wanted) + assert_eq(c.upgrades, [wanted] as Array[StringName]) + assert_eq(c.pending_choices, 0) + assert_true(c.offer.is_empty(), "nothing pending means nothing on the table") + + +func test_taking_without_a_pending_choice_is_refused() -> void: + var c := store.create_character(ACC, "Greedy") + assert_eq(store.take_upgrade(ACC, c.id, 0), &"") + assert_eq(c.upgrades.size(), 0) + + +## The index arrives from a client, so nonsense has to be a refusal rather than +## a crash or a free upgrade. +func test_an_index_outside_the_offer_is_refused() -> void: + var c := store.create_character(ACC, "Sneaky") + store.grant_choices(ACC, c.id, 1) + assert_eq(store.take_upgrade(ACC, c.id, -1), &"") + assert_eq(store.take_upgrade(ACC, c.id, 99), &"") + assert_eq(c.pending_choices, 1, "and costs nothing") + + +func test_a_dead_character_cannot_spend_a_choice() -> void: + var c := store.create_character(ACC, "Late") + store.grant_choices(ACC, c.id, 1) + store.retire_character(ACC, c.id) + assert_eq(store.take_upgrade(ACC, c.id, 0), &"") + + +func test_upgrades_and_pending_choices_survive_a_reload() -> void: + var c := store.create_character(ACC, "Persistent") + store.grant_choices(ACC, c.id, 2) + var taken := store.take_upgrade(ACC, c.id, 0) + var offer := c.offer.duplicate() + + var reloaded := CharacterStore.new(store._path) + assert_true(reloaded.load_from_disk()) + var got := reloaded.get_character(ACC, c.id) + assert_eq(got.upgrades, [taken] as Array[StringName]) + assert_eq(got.pending_choices, 1) + assert_eq(got.offer, offer, + "the table has to survive a restart, or it is a reroll on every crash") + + +## Upgrades change what a character IS, so the roster has to show the real +## number rather than the one the level alone implies. +func test_the_roster_health_reflects_the_upgrades_taken() -> void: + var c := store.create_character(ACC, "Fragile") + var before := c.max_hp() + c.upgrades.append(Upgrades.GLASS_CANNON) + assert_eq(c.max_hp(), before / 2) + + +## A save written by a build with an upgrade this one lacks must not leave a +## phantom that counts toward the flat damage bonus and does nothing else. +func test_an_unknown_upgrade_in_a_save_is_dropped() -> void: + var restored := Character.from_dict({ + "id": "z", "name": "Old", "xp": 0, + "upgrades": ["sniper", "telekinesis"], + "pending_choices": 2, + "offer": ["poison", "not_a_thing"], + }) + assert_eq(restored.upgrades, [Upgrades.SNIPER] as Array[StringName]) + assert_eq(restored.offer, [Upgrades.POISON] as Array[StringName]) + assert_eq(restored.pending_choices, 2) diff --git a/tests/unit/test_interest.gd b/tests/unit/test_interest.gd index 6c28308..01ef77d 100644 --- a/tests/unit/test_interest.gd +++ b/tests/unit/test_interest.gd @@ -83,7 +83,11 @@ func test_omitting_the_observer_encodes_the_whole_world() -> void: ## real content rather than guessed. Adding a faster or longer-lived bullet ## should fail here rather than produce invisible damage in play. func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void: - var worst := SimConfig.PLAYER_BULLET_SPEED \ + # The player's shot is the longest-travelling bullet in the game once + # upgrades are involved, so the worst case is measured at the speed ceiling + # rather than at the base speed. test_interest used to model only static + # content, which upgrades quietly invalidated. + var worst := SimConfig.MAX_BULLET_SPEED \ * float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA var emitters: Array[BulletEmitter] = [] for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]: @@ -95,3 +99,15 @@ func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void: assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS, "a bullet spawned just outside the radius must not be able to reach " + "ground the player can see before it expires (worst travel %.0f)" % worst) + + +## The ceiling is what makes the radius above computable at all. Without it a +## stack of Snipers would push bullet travel past whatever radius was chosen, +## and the failure would be invisible damage rather than a failing test. +func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void: + var ids: Array[StringName] = [] + for _i in 8: + ids.append(Upgrades.SNIPER) + var travel := PlayerStats.build(ids).bullet_speed \ + * float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA + assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS) diff --git a/tests/unit/test_net_codec.gd b/tests/unit/test_net_codec.gd index 9b56a16..b0a7224 100644 --- a/tests/unit/test_net_codec.gd +++ b/tests/unit/test_net_codec.gd @@ -436,3 +436,50 @@ func test_an_empty_portal_list_is_safe() -> void: assert_eq(NetCodec.decode_portals( NetCodec.encode_portals([] as Array[SimPortal])).size(), 0) assert_eq(NetCodec.decode_portals(PackedByteArray()).size(), 0) + + +# --- Upgrade state ---------------------------------------------------------- + +func test_upgrade_state_round_trips() -> void: + var offer: Array[StringName] = [Upgrades.SNIPER, Upgrades.POISON, Upgrades.ERASER] + var taken: Array[StringName] = [Upgrades.SPREAD, Upgrades.SPREAD, Upgrades.GLASS_CANNON] + var out := NetCodec.decode_upgrade_state( + NetCodec.encode_upgrade_state(2, offer, taken)) + assert_eq(int(out["pending"]), 2) + assert_eq(out["offer"], offer) + assert_eq(out["taken"], taken, "duplicates are meaningful and must survive") + + +func test_an_empty_upgrade_state_is_safe() -> void: + var none: Array[StringName] = [] + var out := NetCodec.decode_upgrade_state( + NetCodec.encode_upgrade_state(0, none, none)) + assert_eq(int(out["pending"]), 0) + assert_eq((out["offer"] as Array).size(), 0) + assert_eq((out["taken"] as Array).size(), 0) + assert_eq(int(NetCodec.decode_upgrade_state(PackedByteArray())["pending"]), 0) + + +func test_a_truncated_upgrade_packet_does_not_read_past_the_end() -> void: + var offer: Array[StringName] = [Upgrades.SNIPER, Upgrades.POISON] + var taken: Array[StringName] = [Upgrades.SPREAD, Upgrades.DOUBLESHOT, Upgrades.ERASER] + var full := NetCodec.encode_upgrade_state(1, offer, taken) + for cut in range(1, full.size()): + var out := NetCodec.decode_upgrade_state(full.slice(0, cut)) + assert_lte((out["offer"] as Array).size(), 2) + assert_lte((out["taken"] as Array).size(), 3) + assert_eq((NetCodec.decode_upgrade_state(full)["taken"] as Array).size(), 3) + + +## An index this build does not know decodes to nothing and is dropped, rather +## than becoming whatever upgrade happens to sit at that position. +func test_an_unknown_upgrade_index_is_dropped_not_guessed() -> void: + var b := StreamPeerBuffer.new() + b.big_endian = false + b.put_u8(1) + b.put_u8(2) + b.put_u8(Upgrades.index_of(Upgrades.SNIPER)) + b.put_u8(200) + b.put_u16(0) + var out := NetCodec.decode_upgrade_state(b.data_array) + assert_eq(out["offer"], [Upgrades.SNIPER] as Array[StringName]) diff --git a/tests/unit/test_upgrade_combat.gd b/tests/unit/test_upgrade_combat.gd new file mode 100644 index 0000000..2bca323 --- /dev/null +++ b/tests/unit/test_upgrade_combat.gd @@ -0,0 +1,417 @@ +extends GutTest +## What upgrades actually do in the simulation: shot geometry, splitting, +## poison and the eraser. +## +## Driven through the real firing and hit-resolution paths rather than by +## calling the effects directly, because the claim being tested is that +## SimWorld needs no per-upgrade branches -- only that PlayerStats reaches it. + +const ME := 1 + +var world: SimWorld +var me: SimPlayer + + +func before_each() -> void: + world = SimWorld.new(11) + me = world.add_player(ME, "me") + me.pos = Vector2.ZERO + me.spawn_grace = 0 + + +func _give(ids: Array) -> void: + var typed: Array[StringName] = [] + for id in ids: + typed.append(id) + me.stats = PlayerStats.build(typed) + me.recompute_max_hp() + + +## One trigger pull, aimed along +x. +func _fire_once() -> int: + me.aim = 0.0 + me.fire_cooldown = 0 + world.pool.clear() + var frames: Array[InputFrame] = [ + InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_FIRE)] + world.queue_input(ME, frames) + world.step() + return world.pool.live_count + + +func _player_bullets() -> Array[int]: + var out: Array[int] = [] + for i in world.pool.high_water: + if world.pool.alive[i] == 1 and world.pool.team[i] == SimConfig.TEAM_PLAYER: + out.append(i) + return out + + +# --- Shot geometry ---------------------------------------------------------- + +func test_a_plain_shot_is_one_bullet() -> void: + assert_eq(_fire_once(), 1) + + +func test_spread_adds_two_and_fans_them() -> void: + _give([Upgrades.SPREAD]) + assert_eq(_fire_once(), 3) + var angles: Array[float] = [] + for i in _player_bullets(): + angles.append(world.pool.vel[i].angle()) + angles.sort() + assert_almost_eq(angles[1], 0.0, 0.01, "the aimed shot is still dead ahead") + assert_almost_eq(angles[0], -deg_to_rad(SimConfig.SPREAD_STEP_DEG), 0.01) + assert_almost_eq(angles[2], deg_to_rad(SimConfig.SPREAD_STEP_DEG), 0.01) + + +func test_two_spreads_widen_the_cone_rather_than_doubling_the_middle() -> void: + _give([Upgrades.SPREAD, Upgrades.SPREAD]) + assert_eq(_fire_once(), 5) + var widest := 0.0 + for i in _player_bullets(): + widest = maxf(widest, absf(world.pool.vel[i].angle())) + assert_almost_eq(widest, deg_to_rad(SimConfig.SPREAD_STEP_DEG * 2.0), 0.01) + + +## Parallel, not fanned: same heading, offset sideways. That is the difference +## between Doubleshot and Spread, and it is entirely in the geometry. +func test_doubleshot_adds_a_parallel_projectile() -> void: + _give([Upgrades.DOUBLESHOT]) + assert_eq(_fire_once(), 2) + var offsets: Array[float] = [] + for i in _player_bullets(): + assert_almost_eq(world.pool.vel[i].angle(), 0.0, 0.01, + "parallel means the same heading") + offsets.append(world.pool.pos[i].y) + assert_gt(absf(offsets[0] - offsets[1]), 1.0, "and a different lane") + + +func test_spread_and_doubleshot_stack_into_one_volley() -> void: + _give([Upgrades.SPREAD, Upgrades.DOUBLESHOT]) + assert_eq(_fire_once(), 4, "one aimed, two fanned, one parallel") + + +func test_sniper_slows_the_trigger_and_speeds_the_bullet() -> void: + _give([Upgrades.SNIPER]) + _fire_once() + assert_eq(me.fire_cooldown, SimConfig.PLAYER_FIRE_COOLDOWN * 2) + for i in _player_bullets(): + assert_almost_eq(world.pool.vel[i].length(), + SimConfig.PLAYER_BULLET_SPEED * 2.0, 1.0) + + +func test_a_shot_carries_the_shooters_upgrades_onto_the_bullet() -> void: + _give([Upgrades.SPLIT_SHOT, Upgrades.POISON, Upgrades.ERASER]) + _fire_once() + for i in _player_bullets(): + assert_eq(world.pool.split[i], 1) + assert_gt(world.pool.poison[i], 0.0) + assert_gt(world.pool.erase[i], 0.0) + + +## A bullet keeps what it was fired with. Taking Poison while a shot is in +## flight must not reach back and poison it. +func test_a_bullet_in_flight_is_not_changed_by_a_later_upgrade() -> void: + _fire_once() + var before := _player_bullets() + _give([Upgrades.POISON]) + for i in before: + assert_eq(world.pool.poison[i], 0.0) + + +# --- Split shot ------------------------------------------------------------- + +func _shoot_at(target: Vector2, split: int, poison: float = 0.0, + erase: float = 0.0) -> int: + # Placed just short of the target with a crawl, so exactly one tick of + # integration puts it inside and hit resolution runs on it. + var slot := world.pool.spawn(target - Vector2(1.0, 0.0), Vector2(60.0, 0.0), + SimConfig.PLAYER_BULLET_RADIUS, 90, SimConfig.PLAYER_BULLET_DAMAGE, + SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT) + world.pool.set_mods(slot, split, poison, erase) + return slot + + +func test_a_hit_with_a_split_charge_produces_two_children() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 100000 + _shoot_at(e.pos, 1) + world.step() + assert_eq(_player_bullets().size(), 2, "the parent is consumed, two children remain") + + +func test_the_children_leave_at_forty_five_degrees_to_either_side() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 100000 + _shoot_at(e.pos, 1) + world.step() + var angles: Array[float] = [] + for i in _player_bullets(): + angles.append(rad_to_deg(world.pool.vel[i].angle())) + angles.sort() + assert_almost_eq(angles[0], -SimConfig.SPLIT_ANGLE_DEG, 0.5) + assert_almost_eq(angles[1], SimConfig.SPLIT_ANGLE_DEG, 0.5) + + +## Children are born past the target. Spawned on top of it they would be +## resolved against it again on the same tick -- a free second hit, and with +## several charges a free chain of them. +func test_children_do_not_immediately_hit_the_enemy_they_were_born_on() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 100000 + var before := e.hp + _shoot_at(e.pos, 1) + # Several ticks, not one: children spawned during hit resolution are not + # visited again until the NEXT tick, so a single step cannot tell a child + # born clear of the enemy from one born inside it. + for _i in 8: + world.step() + assert_eq(before - e.hp, SimConfig.PLAYER_BULLET_DAMAGE, + "exactly one hit's worth of damage, not three") + + +func test_a_child_of_a_single_charge_cannot_split_again() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 100000 + _shoot_at(e.pos, 1) + world.step() + for i in _player_bullets(): + assert_eq(world.pool.split[i], 0, "the charge was spent by the parent") + + +func test_two_charges_let_a_child_split_once_more() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 100000 + _shoot_at(e.pos, 2) + world.step() + for i in _player_bullets(): + assert_eq(world.pool.split[i], 1) + + +func test_a_shot_with_no_charges_does_not_split() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 100000 + _shoot_at(e.pos, 0) + world.step() + assert_eq(_player_bullets().size(), 0) + + +func test_splitting_works_on_the_boss_too() -> void: + var boss := world.spawn_boss(Content.warden()) + boss.pos = Vector2(150.0, 0.0) + _shoot_at(boss.pos, 1) + world.step() + assert_eq(_player_bullets().size(), 2) + + +# --- Poison ----------------------------------------------------------------- + +func test_a_poisoned_hit_keeps_damaging_after_the_bullet_is_gone() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 100000 + _shoot_at(e.pos, 0, 0.5) + world.step() + var after_hit := e.hp + for _i in SimConfig.POISON_DURATION_TICKS: + world.step() + assert_lt(e.hp, after_hit, "poison should have kept working") + + +## The user's decision: stacks are independent, so a second dose adds to the +## first rather than replacing it. +func test_doses_stack_rather_than_refreshing() -> void: + var one := PoisonTrack.new() + one.add(600.0, 0) + var two := PoisonTrack.new() + two.add(600.0, 0) + two.add(600.0, 0) + var single := 0 + var double := 0 + # From tick 0, the way the world drives it: a dose applied on tick T ticks + # on T and the DURATION-1 ticks after it. + for t in SimConfig.POISON_DURATION_TICKS: + single += one.step(t) + double += two.step(t) + assert_almost_eq(float(single), 600.0, 2.0) + assert_almost_eq(float(double), 1200.0, 2.0, + "two doses must deliver two doses' worth") + + +func test_a_dose_delivers_its_whole_value_over_the_window() -> void: + var track := PoisonTrack.new() + track.add(300.0, 0) + var total := 0 + for t in SimConfig.POISON_DURATION_TICKS: + total += track.step(t) + assert_eq(total, 300, "the whole dose, to the hit point") + track.step(SimConfig.POISON_DURATION_TICKS) + assert_false(track.active(), "and then stops") + + +## Doses all last the same number of ticks, so they expire in the order they +## were added. That is the property that lets the queue be a plain FIFO whose +## front is the only entry ever examined -- and therefore the property that +## makes dozens of concurrent stacks cost nothing. +func test_poison_expires_in_the_order_it_was_applied() -> void: + var track := PoisonTrack.new() + track.add(600.0, 0) + track.add(600.0, 300) + assert_eq(track.dose_count(), 2) + for t in range(0, SimConfig.POISON_DURATION_TICKS + 1): + track.step(t) + assert_true(track.active(), "the later dose is still running") + assert_eq(track.dose_count(), 1, "the earlier one retired first") + for t in range(SimConfig.POISON_DURATION_TICKS + 1, 300 + SimConfig.POISON_DURATION_TICKS + 1): + track.step(t) + assert_false(track.active()) + assert_eq(track.dose_count(), 0) + + +func test_fractions_below_one_hit_point_are_not_lost() -> void: + var track := PoisonTrack.new() + # 3 damage over 600 ticks is 0.005/tick -- it would round to nothing on + # every single tick without the carry. + track.add(3.0, 0) + var total := 0 + for t in SimConfig.POISON_DURATION_TICKS: + total += track.step(t) + assert_eq(total, 3) + + +## Poison must be able to finish something off, or a kill it caused would never +## be scored and the experience would vanish. +func test_a_kill_by_poison_is_still_announced() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 3 + e.poison_track().add(600.0, world.tick) + var died := false + for _i in 120: + world.step() + for ev in world.events: + if int(ev["t"]) == SimEvent.Type.ENEMY_DIED: + died = true + world.drain_events() + assert_true(died, "experience is keyed on this event") + + +## Poison lands many times a second. Putting each tick on the reliable event +## channel would be a flood, and the client learns hp from the snapshot anyway. +func test_poison_ticks_are_not_announced_as_hits() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 100000 + e.poison_track().add(6000.0, world.tick) + world.drain_events() + for _i in 120: + world.step() + var hits := 0 + for ev in world.events: + if int(ev["t"]) == SimEvent.Type.ENEMY_HIT: + hits += 1 + assert_eq(hits, 0) + assert_lt(e.hp, 100000, "but the damage still happened") + + +## The boss takes extra damage in later phases. Poison is derived from the +## damage actually applied, so it inherits that -- and must not be scaled a +## second time when it ticks. +func test_boss_poison_is_scaled_once_not_twice() -> void: + var boss := world.spawn_boss(Content.warden()) + boss.pos = Vector2(150.0, 0.0) + # Hurt into a phase that actually has an armour multiplier -- at full health + # the multiplier is 1.0 and scaling twice is indistinguishable from scaling + # once, which is exactly how this test used to pass while proving nothing. + # Two things have to be true for this test to be able to fail. The boss has + # to be in a phase with an armour multiplier at all -- at full health it is + # 1.0, and scaling twice is then indistinguishable from scaling once. And + # the per-tick poison has to be big enough that the multiplier survives + # rounding: at 1 damage a tick, roundi(1 x 1.15) is still 1, so the bug + # hides completely. A large pool gives both. + boss.def.max_hp = 100000 + boss.hp = 20000 # 20% -> a phase that takes extra + world.step() + var armour := boss.current_phase().damage_taken_mult + assert_gt(armour, 1.0, "setup: this phase has to have armour") + var track := boss.poison_track() + track.add(6000.0, world.tick) # 10 damage per tick + var before := boss.hp + for _i in SimConfig.POISON_DURATION_TICKS + 5: + world.step() + var dealt := float(before - boss.hp) + assert_almost_eq(dealt, 6000.0, 60.0, + "scaled a second time this would be about %.0f" % (6000.0 * armour)) + + +func test_a_replica_never_applies_poison() -> void: + var replica := SimWorld.new(1) + replica.authoritative = false + var e := replica.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) + e.hp = 500 + e.poison_track().add(600.0, 0) + for _i in 200: + replica.step() + assert_eq(e.hp, 500, "a client must never decide it dealt damage") + + +# --- Eraser ----------------------------------------------------------------- + +func _enemy_bullet(at: Vector2) -> int: + return world.pool.spawn(at, Vector2.ZERO, 8.0, 600, 5, + SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) + + +## Certainty rather than 1%, so the test measures the mechanism instead of the +## random number generator. +func test_an_erasing_shot_deletes_an_enemy_projectile_it_overlaps() -> void: + var target := Vector2(100.0, 0.0) + var incoming := _enemy_bullet(target) + _shoot_at(target, 0, 0.0, 1.0) + world.step() + assert_eq(world.pool.alive[incoming], 0) + + +func test_the_deletion_is_announced_so_no_client_keeps_a_phantom() -> void: + var target := Vector2(100.0, 0.0) + var incoming := _enemy_bullet(target) + var incoming_uid: int = world.pool.uid[incoming] + world.drain_events() + _shoot_at(target, 0, 0.0, 1.0) + world.step() + var announced := false + for ev in world.events: + if int(ev["t"]) == SimEvent.Type.BULLET_DESPAWN and int(ev["uid"]) == incoming_uid: + announced = true + assert_true(announced) + + +func test_a_shot_without_the_upgrade_passes_straight_through() -> void: + var target := Vector2(100.0, 0.0) + var incoming := _enemy_bullet(target) + _shoot_at(target, 0, 0.0, 0.0) + world.step() + assert_eq(world.pool.alive[incoming], 1, + "enemy bullets are not obstacles for ordinary shots") + + +func test_an_erasing_shot_is_not_consumed_by_erasing() -> void: + var target := Vector2(100.0, 0.0) + _enemy_bullet(target) + var mine := _shoot_at(target, 0, 0.0, 1.0) + world.step() + assert_eq(world.pool.alive[mine], 1, "the shot keeps going") + + +## One roll per shot per tick, not one per overlapping pair. A boss ring puts +## dozens of bullets in the same place, and rolling against each would multiply +## the 1% the player was promised. +func test_only_one_projectile_is_erased_per_tick() -> void: + var target := Vector2(100.0, 0.0) + for _i in 5: + _enemy_bullet(target) + _shoot_at(target, 0, 0.0, 1.0) + world.step() + var left := 0 + for i in world.pool.high_water: + if world.pool.alive[i] == 1 and world.pool.team[i] == SimConfig.TEAM_ENEMY: + left += 1 + assert_eq(left, 4, "four of the five survive the tick") diff --git a/tests/unit/test_upgrade_combat.gd.uid b/tests/unit/test_upgrade_combat.gd.uid new file mode 100644 index 0000000..c9ec645 --- /dev/null +++ b/tests/unit/test_upgrade_combat.gd.uid @@ -0,0 +1 @@ +uid://cvmvcowixcc6s diff --git a/tests/unit/test_upgrades.gd b/tests/unit/test_upgrades.gd new file mode 100644 index 0000000..e0f4c74 --- /dev/null +++ b/tests/unit/test_upgrades.gd @@ -0,0 +1,226 @@ +extends GutTest +## Upgrades: the registry, the damage formula, and how they stack. +## +## The formula is settled (see docs/DECISIONS.md): base x (1 + sum additive) x +## product multiplicative, with every upgrade adding a flat +5% to the additive +## pool on top of whatever else it does. These tests are the record of that. + +const BASE_DMG := SimConfig.PLAYER_BULLET_DAMAGE + + +func _stats(ids: Array) -> PlayerStats: + var typed: Array[StringName] = [] + for id in ids: + typed.append(id) + return PlayerStats.build(typed) + + +func test_every_registered_id_has_a_definition() -> void: + for id in Upgrades.ORDER: + assert_not_null(Upgrades.get_def(id), "%s is in ORDER with no definition" % id) + + +func test_the_wire_index_round_trips() -> void: + for id in Upgrades.ORDER: + assert_eq(Upgrades.by_index(Upgrades.index_of(id)), id) + assert_gt(Upgrades.index_of(id), 0, "0 is reserved for 'no upgrade'") + assert_eq(Upgrades.by_index(0), &"") + assert_eq(Upgrades.by_index(250), &"") + assert_eq(Upgrades.index_of(&"telekinesis"), 0) + + +func test_the_index_fits_the_byte_the_wire_gives_it() -> void: + assert_lte(Upgrades.ORDER.size(), 254) + + +func test_no_upgrades_means_exactly_the_base_numbers() -> void: + var s := _stats([]) + assert_eq(s.damage, BASE_DMG) + assert_eq(s.fire_cooldown, SimConfig.PLAYER_FIRE_COOLDOWN) + assert_eq(s.bullet_speed, SimConfig.PLAYER_BULLET_SPEED) + assert_eq(s.max_hp_mult, 1.0) + assert_eq(s.upgrade_count, 0) + + +## The +5% is a property of taking an upgrade, not of any particular one, so +## even an upgrade with no damage effect of its own raises damage. +func test_every_upgrade_carries_the_flat_damage_bonus() -> void: + for id in Upgrades.ORDER: + var s := _stats([id]) + var def := Upgrades.get_def(id) + var expected := roundi(float(BASE_DMG) + * (1.0 + SimConfig.UPGRADE_DAMAGE_BONUS + def.damage_add) + * def.damage_mult) + assert_eq(s.damage, maxi(1, expected), "%s" % id) + + +func test_split_shot_alone_still_raises_damage_by_the_flat_bonus() -> void: + assert_eq(_stats([Upgrades.SPLIT_SHOT]).damage, + roundi(float(BASE_DMG) * (1.0 + SimConfig.UPGRADE_DAMAGE_BONUS))) + + +## The settled formula, spelled out on the exact case that distinguishes it +## from the alternatives: additive percentages pool, and Sniper multiplies the +## pooled result rather than joining it. +func test_the_damage_formula_is_additive_pool_times_multiplicative() -> void: + var s := _stats([Upgrades.GLASS_CANNON, Upgrades.SPREAD, Upgrades.SNIPER]) + # +5% x3 = +15%, glass cannon +100%, spread -10% -> pool = 2.05 + # sniper x2 on top. + assert_eq(s.damage, roundi(float(BASE_DMG) * 2.05 * 2.0)) + + +func test_two_snipers_multiply_rather_than_add() -> void: + var one := _stats([Upgrades.SNIPER]).damage + var two := _stats([Upgrades.SNIPER, Upgrades.SNIPER]).damage + # If Sniper were additive, two of them would be far less than four times + # the base. Multiplicative means the second is worth as much as the first. + assert_gt(float(two) / float(one), 1.9) + + +func test_damage_never_falls_below_one() -> void: + var stacked: Array[StringName] = [] + for _i in 6: + stacked.append(Upgrades.DOUBLESHOT) # -50% each + assert_gte(PlayerStats.build(stacked).damage, 1, + "a shot that deals nothing is indistinguishable from a bug") + + +# --- Fire rate, speed, health ------------------------------------------------ + +## "Half the fire rate" has to mean half the shots per second, which is a +## doubled cooldown -- halving the cooldown would do the opposite of the brief. +func test_sniper_halves_the_shots_per_second() -> void: + assert_eq(_stats([Upgrades.SNIPER]).fire_cooldown, + SimConfig.PLAYER_FIRE_COOLDOWN * 2) + + +func test_the_cooldown_never_reaches_zero() -> void: + var many: Array[StringName] = [] + for _i in 10: + many.append(Upgrades.SNIPER) + assert_gte(PlayerStats.build(many).fire_cooldown, 1) + + +func test_sniper_doubles_bullet_speed_up_to_the_ceiling() -> void: + assert_eq(_stats([Upgrades.SNIPER]).bullet_speed, + SimConfig.PLAYER_BULLET_SPEED * 2.0) + assert_false(_stats([Upgrades.SNIPER]).speed_capped) + var two := _stats([Upgrades.SNIPER, Upgrades.SNIPER]) + assert_eq(two.bullet_speed, SimConfig.MAX_BULLET_SPEED) + assert_true(two.speed_capped, "the screen has to be able to say so") + + +## Half of the LEVELLED maximum, so the trade stays a real price at level 15 +## instead of fading to a rounding error. +func test_glass_cannon_halves_the_levelled_maximum() -> void: + assert_eq(_stats([Upgrades.GLASS_CANNON]).max_hp_mult, 0.5) + var p := SimPlayer.new() + p.level = Progression.MAX_LEVEL + p.stats = _stats([Upgrades.GLASS_CANNON]) + p.recompute_max_hp() + assert_eq(p.max_hp, Progression.max_hp_for_level(Progression.MAX_LEVEL) / 2) + + +func test_two_glass_cannons_multiply() -> void: + assert_almost_eq(_stats([Upgrades.GLASS_CANNON, Upgrades.GLASS_CANNON]).max_hp_mult, + 0.25, 0.0001) + + +func test_health_can_never_be_scaled_to_zero() -> void: + var many: Array[StringName] = [] + for _i in 12: + many.append(Upgrades.GLASS_CANNON) + var p := SimPlayer.new() + p.stats = PlayerStats.build(many) + p.recompute_max_hp() + assert_gte(p.max_hp, 1) + + +func test_dropping_below_the_new_maximum_is_immediate() -> void: + var p := SimPlayer.new() + p.hp = p.max_hp + p.stats = _stats([Upgrades.GLASS_CANNON]) + p.recompute_max_hp() + assert_eq(p.hp, p.max_hp, "taking glass cannon at full health must not leave you over cap") + + +# --- Shot counts ------------------------------------------------------------- + +func test_spread_and_doubleshot_add_projectiles() -> void: + assert_eq(_stats([Upgrades.SPREAD]).side_shots, 2) + assert_eq(_stats([Upgrades.SPREAD, Upgrades.SPREAD]).side_shots, 4) + assert_eq(_stats([Upgrades.DOUBLESHOT]).parallel_shots, 1) + + +func test_split_charges_accumulate() -> void: + assert_eq(_stats([Upgrades.SPLIT_SHOT]).split_charges, 1) + assert_eq(_stats([Upgrades.SPLIT_SHOT, Upgrades.SPLIT_SHOT]).split_charges, 2) + + +func test_erase_chance_is_clamped_to_certainty() -> void: + var many: Array[StringName] = [] + for _i in 200: + many.append(Upgrades.ERASER) + assert_lte(PlayerStats.build(many).erase_chance, 1.0) + + +## A save from a build with an upgrade this one lacks must produce coherent +## stats, not a phantom that counts toward the +5% and does nothing else. +func test_an_unknown_upgrade_is_ignored_entirely() -> void: + var mixed: Array[StringName] = [Upgrades.SNIPER, &"telekinesis"] + var s := PlayerStats.build(mixed) + assert_eq(s.upgrade_count, 1) + assert_eq(s.damage, _stats([Upgrades.SNIPER]).damage) + + +# --- Offers ------------------------------------------------------------------ + +func test_an_offer_has_the_configured_number_of_distinct_options() -> void: + var rng := RandomNumberGenerator.new() + for seed_value in 40: + rng.seed = seed_value + var offer := Upgrades.roll_offer(rng, SimConfig.UPGRADE_CHOICES) + assert_eq(offer.size(), SimConfig.UPGRADE_CHOICES) + var seen := {} + for id in offer: + assert_false(seen.has(id), "the same option twice in one offer") + seen[id] = true + assert_not_null(Upgrades.get_def(id)) + + +## Rarity has to actually mean something, or the weights are decoration. +func test_commons_come_up_far_more_often_than_legendaries() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 99 + var counts := {} + for _i in 600: + for id in Upgrades.roll_offer(rng, 1): + counts[id] = int(counts.get(id, 0)) + 1 + var commons := int(counts.get(Upgrades.SPLIT_SHOT, 0)) + var legendary := int(counts.get(Upgrades.ERASER, 0)) + assert_gt(commons, legendary * 3, + "a legendary that turns up as often as a common is not a legendary") + + +## Asking for more options than exist must terminate rather than spin looking +## for a distinct one it can never find. +func test_asking_for_more_options_than_exist_is_safe() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 3 + var offer := Upgrades.roll_offer(rng, Upgrades.ORDER.size() + 5) + assert_eq(offer.size(), Upgrades.ORDER.size()) + + +## Duplicates across offers are the point: taking Split Shot twice is how a +## shot splits twice, so an upgrade you hold must still be offerable. +func test_an_upgrade_you_already_hold_can_be_offered_again() -> void: + var rng := RandomNumberGenerator.new() + var seen_repeat := false + for seed_value in 60: + rng.seed = seed_value + var a := Upgrades.roll_offer(rng, 1) + var b := Upgrades.roll_offer(rng, 1) + if not a.is_empty() and a == b: + seen_repeat = true + break + assert_true(seen_repeat, "offers must not exclude what you already have") diff --git a/tests/unit/test_upgrades.gd.uid b/tests/unit/test_upgrades.gd.uid new file mode 100644 index 0000000..62d2dff --- /dev/null +++ b/tests/unit/test_upgrades.gd.uid @@ -0,0 +1 @@ +uid://dx1wdyhuevon8 diff --git a/tools/diag_upgrades.gd b/tools/diag_upgrades.gd new file mode 100644 index 0000000..2ed5742 --- /dev/null +++ b/tools/diag_upgrades.gd @@ -0,0 +1,209 @@ +extends Node +## End-to-end check of the upgrade loop: level -> banked choice -> the three +## on the table -> spent at the NPC -> new stats -> persisted. +## +## godot --headless --path . res://tools/diag_upgrades.tscn +## +## Runs as a scene because ServerRuntime needs the Net autoload. Exits non-zero +## on any failure, so it gates like a test. +## +## The unit tests cover the formula and the store in isolation. What only shows +## up here is the wiring between them: that a level earned in a dungeon reaches +## the hub as a choice, that the server refuses one made from the wrong place, +## and that taking it changes the numbers the player is actually shooting with. + +const STORE_PATH := "user://diag_upgrades.json" + +var _fails: Array[String] = [] +var _step: int = 0 +var _srv: ServerRuntime +var _account: int = 616161 +var _character: Character +var _offer_before: Array[StringName] = [] +var _damage_before: int = 0 + + +func _ready() -> void: + DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) + GameOpts.bot_client = true + GameOpts.account_override = _account + if Net.host(27403) != OK: + push_error("could not host") + get_tree().quit(1) + return + _srv = Net.server + _srv.store = CharacterStore.new(STORE_PATH) + _srv.store.load_from_disk() + Net.start_local_client() + + +func _check(ok: bool, what: String) -> void: + if ok: + print(" ok %s" % what) + else: + print(" FAIL %s" % what) + _fails.append(what) + + +func _me() -> SimPlayer: + var inst := _srv.instance_of(Net.LOCAL_PEER) + return inst.world.players.get(Net.LOCAL_PEER) if inst != null else null + + +func _stored() -> Character: + return _srv.store.get_character(_account, _character.id) + + +func _physics_process(_delta: float) -> void: + _step += 1 + match _step: + 20: _login() + 30: _earn_a_level() + 40: _refused_in_a_dungeon() + 50: _back_to_the_hub() + 60: _refused_away_from_the_npc() + 70: _take_it_at_the_npc() + 80: _it_changed_things() + 90: _refused_with_nothing_pending() + 100: _finish() + + +func _login() -> void: + _character = _srv.store.last_played(_account) + _check(_character != null, "a character exists after login") + if _character == null: + _finish() + return + _check(_character.upgrades.is_empty(), "and starts with no upgrades") + _check(_character.pending_choices == 0, "and nothing to spend") + _check(Net.client.upgrades_pending == 0, "the client agrees") + _damage_before = _me().stats.damage + _check(_damage_before == SimConfig.PLAYER_BULLET_DAMAGE, + "shooting for the base damage") + + +## Levelling happens in a dungeon; the NPC is in the hub. The choice has to +## survive the journey or it would be lost the moment it was earned. +func _earn_a_level() -> void: + _srv._send_to_dungeon(Net.LOCAL_PEER) + _srv._grant_xp(Net.LOCAL_PEER, Progression.total_xp_for_level(3)) + var c := _stored() + _check(c.level >= 2, "experience produced a level (now %d)" % c.level) + _check(c.pending_choices == c.level - 1, + "one choice per level gained (%d)" % c.pending_choices) + _check(c.offer.size() == SimConfig.UPGRADE_CHOICES, + "and %d options on the table" % SimConfig.UPGRADE_CHOICES) + _check(Net.client.upgrades_pending == c.pending_choices, + "the client was told without asking") + _check(Net.client.upgrade_offer.size() == SimConfig.UPGRADE_CHOICES, + "and was given the same table") + _offer_before = c.offer.duplicate() + + +func _refused_in_a_dungeon() -> void: + Net.choose_upgrade(0) + _check(_stored().upgrades.is_empty(), + "an upgrade cannot be taken from inside a dungeon") + _check(_stored().offer == _offer_before, + "and the refusal does not reroll the table") + + +func _back_to_the_hub() -> void: + _srv._send_to_lobby(Net.LOCAL_PEER) + # Stop the bot steering itself around the hub -- see _stand_at_the_npc(). + Net.client.set_physics_process(false) + + +func _refused_away_from_the_npc() -> void: + var inst := _srv.instance_of(Net.LOCAL_PEER) + _check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY, + "back in the hub") + var p := _me() + # Far from the quartermaster. Where a player is standing is the one thing a + # modified client cannot fake, which is why the check lives here and not in + # the UI that greys out the button. + p.pos = inst.world.upgrade_npc + Vector2(SimConfig.UPGRADE_NPC_RADIUS * 4.0, 0.0) + Net.choose_upgrade(0) + _check(_stored().upgrades.is_empty(), + "standing across the room is not standing at the NPC") + _check(_stored().pending_choices > 0, "and the choice is still owed") + + +## Standing at the NPC is set deliberately from here on. Whether the player +## happens to be near it is exactly what is under test, and a bot steering +## itself around the hub would decide that at random. +func _stand_at_the_npc() -> void: + var inst := _srv.instance_of(Net.LOCAL_PEER) + if inst != null and _me() != null: + _me().pos = inst.world.upgrade_npc + + +func _take_it_at_the_npc() -> void: + var inst := _srv.instance_of(Net.LOCAL_PEER) + var owed_before := _stored().pending_choices + _stand_at_the_npc() + _check(inst.world.at_upgrade_npc(_me().pos), "setup: standing at the NPC") + Net.choose_upgrade(1) + var c := _stored() + _check(c.upgrades.size() == 1, "the upgrade is taken") + _check(c.upgrades[0] == _offer_before[1], + "and it is the one the index named, from the server's own table") + _check(c.pending_choices == owed_before - 1, + "exactly one choice was spent (%d -> %d)" % [owed_before, c.pending_choices]) + _check(not c.offer.is_empty() or c.pending_choices == 0, + "a table is on offer exactly while something is pending") + _check(c.offer != _offer_before, "and the next table is a fresh one") + + +func _it_changed_things() -> void: + var p := _me() + var c := _stored() + _check(p.stats.upgrade_count == c.upgrades.size(), + "the player in the world is shooting with the new list") + var expected := PlayerStats.build(c.upgrades) + _check(p.stats.damage == expected.damage, + "damage matches the formula (%d -> %d)" % [_damage_before, p.stats.damage]) + _check(p.max_hp == maxi(1, roundi(float(Progression.max_hp_for_level(p.level)) + * expected.max_hp_mult)), "and maximum health follows the multiplier") + _check(Net.client.upgrades_taken.size() == c.upgrades.size(), + "the client's copy was refreshed") + + # Persisted, not merely held in memory: the character store is what makes an + # upgrade survive a restart, and it is written the moment one is taken. + var reloaded := CharacterStore.new(STORE_PATH) + reloaded.load_from_disk() + var saved := reloaded.get_character(_account, _character.id) + _check(saved != null and saved.upgrades == c.upgrades, + "the upgrade is on disk already") + _check(saved != null and saved.offer == c.offer, + "and so is the table, so a crash is not a reroll") + + +func _refused_with_nothing_pending() -> void: + var c := _stored() + # Bounded rather than a plain "while owed": a choice that were ever refused + # here would otherwise spin forever instead of reporting a failure, which is + # precisely what it did the first time this was written. + var guard := Progression.MAX_LEVEL + 2 + while c.pending_choices > 0 and guard > 0: + guard -= 1 + _stand_at_the_npc() + Net.choose_upgrade(0) + c = _stored() + _check(c.pending_choices == 0, "every owed choice could actually be spent") + var held := c.upgrades.size() + _stand_at_the_npc() + Net.choose_upgrade(0) + _check(_stored().upgrades.size() == held, + "with nothing owed, asking again gets nothing") + _check(_stored().offer.is_empty(), "and no table is left on the counter") + + +func _finish() -> void: + print("---") + if _fails.is_empty(): + print("UPGRADES_OK") + else: + print("UPGRADES_FAIL (%d)" % _fails.size()) + Net.shutdown() + get_tree().quit(0 if _fails.is_empty() else 1) diff --git a/tools/diag_upgrades.gd.uid b/tools/diag_upgrades.gd.uid new file mode 100644 index 0000000..c9971af --- /dev/null +++ b/tools/diag_upgrades.gd.uid @@ -0,0 +1 @@ +uid://70ijixd36nt5 diff --git a/tools/diag_upgrades.tscn b/tools/diag_upgrades.tscn new file mode 100644 index 0000000..44c0173 --- /dev/null +++ b/tools/diag_upgrades.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/diag_upgrades.gd" id="1"] + +[node name="DiagUpgrades" type="Node"] +script = ExtResource("1") diff --git a/tools/smoke.sh b/tools/smoke.sh index f9b3d7b..5d3c3f8 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -134,6 +134,11 @@ check "the dropped body is released" "$OUT/server.log" "released from instance check "a polite disconnect leaves too" "$OUT/leavebot.log" "BOT_GRACEFUL_LEAVE" check "a polite disconnect is also channelled" \ "$OUT/server.log" "'leavebot' dropped in instance [0-9]+, channelling out" +# No assertion for upgrades here on purpose. Bots never walk to the +# quartermaster, so anything written would either duplicate a check above or +# assert nothing at all. The upgrade-state message DOES cross the wire on every +# login, so a malformed one shows up in the script-error refutations below -- +# and tools/diag_upgrades.tscn covers the loop itself. refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR" refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"