diff --git a/CLAUDE.md b/CLAUDE.md index 4cf2ba3..ede4e3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,12 +25,14 @@ non-zero on failure, so they gate like tests: ```bash 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 . --script tools/bench.gd # sim cost per tick python3 tools/build_local_assets.py # rebuild the local-only bullet atlas ``` -`diag_progression` exists because the bot smoke test cannot cover progression: -bots are poor shots and rarely kill anything. +`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. Everything after `--` goes to `GameOpts.parse()`: @@ -65,10 +67,17 @@ push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`). **The server decides everything; the client only sends intent.** A client can send exactly two things: an [InputFrame] (move vector, aim angle, -three button bits) and a handshake. There is no message for "I moved here", "I -hit that", "I took damage" or "my escape finished". Adding one would collapse -the whole security model, so don't — validate-after-the-fact is strictly weaker -than having no code path at all. +five button bits, an inventory slot) and a handshake — plus the two low-rate +character-roster requests, which are also pure intent. There is no message for +"I moved here", "I hit that", "I took damage", "my escape finished" or "I now +own this item". Adding one would collapse the whole security model, so don't — +validate-after-the-fact is strictly weaker than having no code path at all. + +When a new player action needs a message, look at whether it fits in the input +frame first. Item use and drop did, and got the redundancy, the replay guard and +the per-tick rate limit for free. The cost was one rule: anything in the input +frame that must not repeat has to be **edge-triggered** (see `prev_buttons`), +because frames are resent and a starved server coasts on the last one it holds. `SimWorld.authoritative` is `true` on the server and `false` on the client. In replica mode the world runs no AI, fires no emitters and resolves no hits; it @@ -80,12 +89,14 @@ 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`. 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. | | `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. | | `src/meta/` | Accounts, characters, persistence, XP curve. Server-owned. | | `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/net/` | Codec, `ServerRuntime`, `ClientRuntime`. | | `src/instances/` | Lobby hub and dungeon runs. | | `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. | @@ -148,9 +159,20 @@ ticks in milliseconds with no SceneTree. - **Bullet speed must stay under one tile per tick.** Wall collision samples position once per tick, so anything faster tunnels. Pinned by `test_bullet_speeds_stay_below_the_tunnelling_threshold`. -- **Only `ServerRuntime` writes progression.** The simulation reads a player's - level and max health; it never grants experience or retires a character. One - writer means a level can never disagree with the experience that earned it. +- **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. + It announces what happened and `ServerRuntime` banks it. One writer means a + level can never disagree with the experience that earned it, and an inventory + on disk can never disagree with the one in the world. +- **`Items.ORDER` is a wire format.** An item's index in it is the byte that + rides the snapshot and every item event. Append, never reorder — reordering + makes every existing client decode a potion as a ration, so it needs a + `Protocol.VERSION` bump. +- **Loot has two visibilities, and the instanced one is enforced in the codec.** + `NetCodec.encode_snapshot` filters items owned by another peer, exactly like + the actor interest radius. Never move that check into the client: hiding an + entity the client was handed defends nothing. - **`LocalAuthProvider` is insecure on purpose.** Any client can claim any account. It exists to have the same shape as Steamworks (opaque ticket in, 64-bit account id out) so swapping is one class. Do not ship it. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a79b968..eed9d07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -124,6 +124,8 @@ after `--`: | `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunked streaming. | | `src/sim/map_gen.gd` | Dungeon generation. `build()` is the only entry point both sides use. | | `src/content/rooms.gd` | Hand-authored stamps: the hub and each boss arena, as text. | +| `src/content/items.gd` | Every item. `Items.ORDER` doubles as the wire format. | +| `src/sim/sim_loot.gd` | An item on the ground. World-shared, or owned by one peer. | | `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. | | `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. Server-owned. | | `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. | diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 72f4cfa..8722de1 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -103,3 +103,10 @@ in RAM and in export size. [`assets/README.md`](../assets/README.md). Art is Stage 2 in [ROADMAP.md](ROADMAP.md); everything currently on screen is drawn from primitives plus one runtime-generated dot texture. + +## Placeholder art worth revisiting + +- **Warden's Ration** draws as a gold flask (`Art.ITEM_ICONS[1]`). The 0x72 + tileset has no food sprite, and the item is deliberately useless flavour, so a + recoloured flask stands in until the art pass. It reads as a potion, which is + the wrong thing for it to read as. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 802dfa3..c52232a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -129,7 +129,7 @@ and troubleshooting — and the player picks another character or creates one. --- -## Characters and progression *(this session)* +## Characters and progression **Level 1 is base health; each level adds 10.** So level 15 is `PLAYER_MAX_HP + 14 * 10` = 240. Level is *derived* from lifetime experience @@ -184,3 +184,68 @@ choice that cannot be taken. re-sent when the *set* of characters changes, so a bar fed from it moved only on level-up or a swap. The live total is four bytes on a message that already goes out at 20 Hz. + +--- + +## Inventory and loot + +**Four slots, permanently on screen.** An inventory you have to open is a menu, +and a menu is a death in a game where the floor is bullets. `INVENTORY_SLOTS` is +one constant that the wire format, the save record and the HUD all read, so +growing it is a one-line change — but not into a paged or scrolling UI. + +**Item actions ride the input frame rather than becoming new messages.** +`InputFrame` gained `BTN_USE`, `BTN_DROP` and a slot byte. Using an item happens +*during* a fight, so it has to be ordered against movement on the same tick and +be as cheap to reject as a movement vector. Riding the existing stream gets the +redundancy that covers a dropped packet, the replay guard on `last_input_tick`, +and a rate limit of one action per tick for free. A separate reliable RPC would +have needed every one of those bolted back on. + +**Item actions are edge-triggered; movement and fire are not.** The client +resends its last few frames every tick and a starved server coasts on the last +one it holds, so a level-triggered read would empty the whole inventory in four +ticks. The *slot* is part of the edge as well — tapping 2 while 1 is still held +is a second, distinct action rather than a swallowed one. + +**Loot has two visibilities, and the instanced one is enforced on the wire.** +World-shared loot is one entity the first player to reach it takes. +Player-instanced loot is one entity per eligible player, and a peer is never +told the other copies exist — the filter lives in `NetCodec.encode_snapshot` +beside the actor interest radius, not in the client. It is an +interest-management rule, not a UI convention. + +**The Warden's Ration is useless on purpose.** It is dropped by every boss, one +per player who was alive for the kill, and does nothing when used. Its job is to +make sure the player-instanced path runs on every single boss kill instead of +being a code path nothing exercises. If it ever gains an effect, that job needs +a new holder. + +**Anything dropped becomes world-shared, whatever it was before.** An instanced +trophy you do not want should be able to reach someone who does — otherwise +"droppable" means nothing for half the items in the game. + +**A potion used at full health is refused, not spent.** Nobody drinks one on +purpose at full health, so a mistimed keypress must not do it for them. The +useless ration, by contrast, *is* consumed: "does nothing" has to mean a +completed transaction or it proves nothing about the path it exists to test. + +**A full bag leaves the item on the floor.** Nothing is destroyed by a failed +pickup, and the failure does not block the portal, which shares the interact +key. + +**Inventories live on the character and are written on every transaction.** +Not on a timer: a crash between "picked it up" and "wrote it down" must not be a +way to lose an item, or — far worse — to duplicate one. They are stored as item +*ids* rather than wire indices, so a save survives `Items.ORDER` being appended +to, and an id this build does not know decays to an empty slot rather than to +the wrong item. + +**Items do not stack.** One id per slot, no count, no charges. Everything the +game currently needs fits that, and the wire format, the save record and the UI +are all simpler for it. Add a count when something actually needs one. + +**Ground loot never expires; each world caps at `MAX_LOOT_PER_INSTANCE`, +oldest evicted.** Dungeons close and take their litter with them, so only the +hub — which never closes and where players can drop things — can realistically +reach the cap. diff --git a/docs/NETCODE.md b/docs/NETCODE.md index c9eea7d..d449522 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -9,17 +9,26 @@ prediction and reconciliation for the local player. client server (60 Hz) | | |-- InputFrame (60 Hz, x3) ---->| queue_input(): validate, drop, sort - | move, aim, 3 button bits | SimWorld.step(): move, AI, emitters, - | | integrate bullets, resolve hits - |<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths - |<-- snapshot (unrel., ch 2) ---| players, enemies, boss @ 20 Hz + | move, aim, 5 button bits, | SimWorld.step(): move, AI, emitters, + | inventory slot | integrate bullets, resolve hits, items + |<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths, + | | item pickups/uses/drops + |<-- snapshot (unrel., ch 2) ---| players, enemies, boss, ground loot, + | | your own inventory @ 20 Hz | | predict locally, reconcile authoritative for everything ``` -The client has no message that expresses a position, a hit, damage taken, or a -completed escape. This is deliberate and stronger than validating such messages -after the fact: there is no code path to exploit, only intent to interpret. +The client has no message that expresses a position, a hit, damage taken, a +completed escape, or an item it now owns. This is deliberate and stronger than +validating such messages after the fact: there is no code path to exploit, only +intent to interpret. + +Item actions are worth noting as the newest thing to resist becoming a message +of its own. Using or dropping an item is a request that reaches the server as +two button bits and a slot number on the ordinary input frame, and the server +decides what — if anything — happened. See *Item actions ride the input frame* +below. ## Why bullets are not replicated as state @@ -187,6 +196,50 @@ is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 from the live content, so adding a faster or longer-lived bullet fails a test rather than producing bullets that wink into existence. +## Loot has two visibilities, and one of them is an interest rule + +Ground loot rides the snapshot rather than an event stream: items do not move, +so re-sending them 20 times a second costs almost nothing and a lost packet +costs nothing at all — which a spawn-once event could not claim. + +Each item is either **world-shared** (`SimLoot.owner_peer == 0`) or +**player-instanced** (owned by exactly one peer). The instanced kind is filtered +in `NetCodec.encode_snapshot`, next to the actor interest radius and for the +same reason: a peer is never *told* that another player's copy exists, so a +modified client has nothing to reveal. Hiding it in the UI would have been the +same class of mistake as relying on fog to hide enemies. + +Two consequences: + +- **An instanced item leaves with its owner.** `SimWorld.remove_player` deletes + loot owned by the departing peer. Nobody else can see or take it, so leaving + it behind would be an invisible entity the instance carries until it closes. +- **Anything dropped becomes world-shared**, whatever it was before. That is + what makes dropping worth having. + +The Warden's Ration exists to keep this path honest: it is dropped instanced on +every boss kill, so the filter runs in every real fight rather than only in +tests. + +## Item actions ride the input frame + +`InputFrame` carries `BTN_USE`, `BTN_DROP` and a slot byte (10 bytes total, up +from 9). The alternative — a reliable `c_use_item(slot)` RPC — would have needed +its own replay guard, its own rate limit, and its own ordering story against the +movement on the same tick. The input stream already has all three. + +The one thing it does not give for free is edge detection. The client repeats +its last few frames every tick (that redundancy is what covers a dropped packet) +and a starved server coasts on the last frame it holds, so a level-triggered +read would spend four items in four ticks. `SimPlayer.prev_buttons` and +`prev_slot` hold the edge; the slot is part of it, so tapping 2 while 1 is still +held is a second action rather than a swallowed one. Movement and fire stay +level-triggered — holding them is exactly what you mean. + +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. + ## No contact damage Nothing hurts you by touching it. Every threat is a bullet you can see and @@ -222,6 +275,8 @@ transition, not a combat mechanic. | Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` | | Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped | | 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" | 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 e26cdee..75a34ad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,14 +20,15 @@ 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` | 206 GUT tests, no SceneTree | ~3s | -| `tools/smoke.sh` | 15 assertions over a real ENet socket: handshake, auth, character creation and persistence, portal, escape, hard kill, polite disconnect | ~40s | +| `tools/test.sh` | 266 GUT tests, no SceneTree | ~3s | +| `tools/smoke.sh` | 16 assertions over a real ENet socket: handshake, auth, character creation and persistence, portal, 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 | -The two diagnostics exist because the smoke test structurally cannot reach what -they cover: bots are poor shots, and a listen server cannot drift its own clock -against itself. +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. --- @@ -119,39 +120,66 @@ path cannot be covered by the bot smoke test, because bots are poor shots. - `--account` and `--store` exist so several clients and test runs can coexist on one machine. A real provider makes `--account` unnecessary. -## Stage 3 — Inventory and loot · *todo, next* +## Stage 3 — Inventory and loot · *done* -Nothing blocks this. The pieces it needs — accounts, characters, a server that -owns per-player state — all exist. - -### Requirements - -| Feature | Decided | Notes | +| Feature | State | Where | | --- | --- | --- | -| Inventory, small enough to sit on screen permanently | **4 slots** | May grow later; do not build a paged or scrolling UI for it. | -| Health potions | rare from normal enemies, **guaranteed from bosses** | The only item with an effect for now. | -| World-shared loot | shared between all players in the instance | First to reach it takes it. | -| A unique, useless food item from bosses | **player-instanced now** | Its whole purpose is to exercise the instanced-loot path rather than defer it. Always dropped by bosses, does nothing when used. | -| Dropping items | any item, back into the world, pickable by others | The path that makes shared loot meaningful. | +| 4 slots, permanently on screen | done | `SimConfig.INVENTORY_SLOTS`, `HUD._draw_inventory` | +| Items defined as data, not code | done | [src/content/items.gd](../src/content/items.gd), [src/actors/items/item_def.gd](../src/actors/items/item_def.gd) | +| Loot tables on enemies and bosses | done | `EnemyDef.loot` / `BossDef.loot`, rolled in `SimWorld._drop_loot` | +| Health potion, rare from trash | done | `Content.TRASH_POTION_CHANCE` = 0.08 | +| …guaranteed from the boss | done | `Content.warden()` loot table, chance 1.0 | +| World-shared loot | done | `SimLoot.owner_peer == 0` | +| Player-instanced loot | done | one `SimLoot` per living player, filtered per peer in `NetCodec.encode_snapshot` | +| Warden's Ration — useless, instanced | done | `Items.wardens_ration()` | +| Pick up, use, drop | done | `SimWorld._try_pickup` / `_use_slot` / `_drop_slot` | +| Inventories persist | done | stored on `Character`, written by `ServerRuntime._persist_inventory` | +| Ground loot drawn with a pickup prompt | done | `WorldView._draw_loot`, `HUD._draw_pickup_prompt` | -### What this implies +Controls: **E** picks up, **1–4** use a slot, **shift+1–4** drop one. -Two loot *visibilities* have to exist from the start, because the food item is -specifically there to prove the second one works: +### The decisions worth knowing before touching this -- **World-shared:** one entity in the instance, visible to everyone, gone when - anyone picks it up. -- **Player-instanced:** one entity per eligible player, each seeing and taking - only their own. Others must not see it, which makes it an interest-management - question as much as a loot one — see `ACTOR_INTEREST_RADIUS` and how the - snapshot is already encoded per peer. +**Item actions ride the input frame; they are not new messages.** `InputFrame` +gained `BTN_USE`, `BTN_DROP` and a slot byte. That buys the redundancy that +covers a dropped packet, the replay guard on `last_input_tick`, ordering against +movement on the same tick, and a natural rate limit of one action per tick — all +of which a separate reliable RPC would have needed bolted back on. -Server owns all of it: the client sends "I want to pick that up" as intent and -learns the outcome. There is no message that grants an item. +**Item actions are edge-triggered; movement and fire are not.** The client +repeats its last few frames every tick and a starved server coasts on the last +one it was given, so a level-triggered read empties the whole inventory in four +ticks. `SimPlayer.prev_buttons` and `prev_slot` hold the edge, and the *slot* is +part of it — tapping 2 while 1 is held is a second, distinct action. -Persistence is an open question — inventories are not in `CharacterStore` yet, -and a potion that vanishes on server restart may or may not matter at this -stage. +**Instanced loot is enforced on the wire, not in the client.** A peer is never +told another player's copy exists. That makes it an interest-management rule of +the same kind as `ACTOR_INTEREST_RADIUS`, and it is why the ration is worth +having: every boss kill exercises the path. + +**Anything dropped becomes world-shared, even if it arrived instanced.** That is +what makes dropping worth having — a trophy you do not want should be able to +reach someone who does. + +**A potion at full health is refused rather than spent.** Nobody drinks one on +purpose at full health, so a mistimed keypress must not do it for them. + +**A full bag leaves the item on the floor** and does not block the portal, which +shares the interact key. + +### Known gaps + +- **Nothing sells items.** Loot only comes from kills; the hub has no source. + The Stage 4 upgrade NPC is the natural place, and is the reason this is a gap + rather than a decision. +- **No stacking.** Four potions take four slots. A count byte per slot is cheap + to add; nothing needed it yet, so the wire, the save record and the HUD all + stayed simpler for not having one. +- **Ground loot never expires**, it is only capped at + `SimConfig.MAX_LOOT_PER_INSTANCE` per world, oldest evicted. Dungeons close + and take their litter with them; only the hub can realistically reach the cap. +- **The ration draws as a gold flask.** The tileset has no food sprite. See + [ASSETS.md](ASSETS.md). --- @@ -264,8 +292,12 @@ several have no obvious default. 8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it in play. Depth drives map size and could drive difficulty and rewards. -9. **Do inventories persist?** Characters do. A potion surviving a server - restart may or may not matter at this stage. -10. **Attribution for four asset packs.** See [ASSETS.md](ASSETS.md) — two are +9. **Where do items come from outside a dungeon?** Loot only drops from kills. + If the hub should sell potions, that is the Stage 4 NPC's second job — and + it needs a currency, which the game does not have. +10. **Should items stack?** Four potions currently take four slots, which makes + a 4-slot bag small. Stacking is a count byte per slot plus a rule for + splitting one; neither is hard, but both change the UI. +11. **Attribution for four asset packs.** See [ASSETS.md](ASSETS.md) — two are non-redistributable and local-only, and there is no in-game credits screen yet, which CC BY 4.0 requires for the audio. diff --git a/project.godot b/project.godot index f36e392..5dbfcfc 100644 --- a/project.godot +++ b/project.godot @@ -89,6 +89,26 @@ debug_overlay={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +use_slot_1={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +use_slot_2={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +use_slot_3={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +use_slot_4={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":52,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/resources/bosses/warden.tres b/resources/bosses/warden.tres index 7a15672..ac370de 100644 --- a/resources/bosses/warden.tres +++ b/resources/bosses/warden.tres @@ -1,15 +1,25 @@ [gd_resource type="Resource" script_class="BossDef" format=3] -[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="1_7m6h6"] -[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="2_b0fvw"] -[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="3_f5abt"] -[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="4_tmo3e"] -[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="5_rswgd"] -[ext_resource type="Script" path="res://src/sim/patterns/arc_sweep_emitter.gd" id="6_v02no"] -[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="7_x6ufy"] +[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="1_b0fvw"] +[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="2_f5abt"] +[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="3_tmo3e"] +[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="4_rswgd"] +[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="5_v02no"] +[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="6_x6ufy"] +[ext_resource type="Script" path="res://src/sim/patterns/arc_sweep_emitter.gd" id="7_bbcbd"] +[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="8_axg03"] -[sub_resource type="Resource" id="Resource_bbcbd"] -script = ExtResource("3_f5abt") +[sub_resource type="Resource" id="Resource_s17mk"] +script = ExtResource("1_b0fvw") +item = &"health_potion" + +[sub_resource type="Resource" id="Resource_0452f"] +script = ExtResource("1_b0fvw") +item = &"wardens_ration" +instanced = true + +[sub_resource type="Resource" id="Resource_1p7x0"] +script = ExtResource("4_rswgd") count = 14 spin_per_shot_deg = 9.0 muzzle_offset = 46.0 @@ -19,8 +29,8 @@ radius = 8.0 lifetime = 420 damage = 14 -[sub_resource type="Resource" id="Resource_axg03"] -script = ExtResource("4_tmo3e") +[sub_resource type="Resource" id="Resource_a0mrn"] +script = ExtResource("5_v02no") spread_deg = 26.0 muzzle_offset = 46.0 start_tick = 120 @@ -29,15 +39,15 @@ speed = 210.0 radius = 6.0 kind = 2 -[sub_resource type="Resource" id="Resource_s17mk"] -script = ExtResource("1_7m6h6") +[sub_resource type="Resource" id="Resource_pfjxl"] +script = ExtResource("2_f5abt") name = "Opening Fold" loop_ticks = 480 telegraph_ticks = 60 -emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_bbcbd"), SubResource("Resource_axg03")]) +emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_1p7x0"), SubResource("Resource_a0mrn")]) -[sub_resource type="Resource" id="Resource_0452f"] -script = ExtResource("3_f5abt") +[sub_resource type="Resource" id="Resource_txml3"] +script = ExtResource("4_rswgd") count = 4 spin_per_shot_deg = 23.0 muzzle_offset = 46.0 @@ -45,8 +55,8 @@ interval = 9 speed = 120.0 lifetime = 480 -[sub_resource type="Resource" id="Resource_1p7x0"] -script = ExtResource("5_rswgd") +[sub_resource type="Resource" id="Resource_k1e8f"] +script = ExtResource("6_x6ufy") count = 20 gap_step = 7 start_tick = 90 @@ -56,15 +66,15 @@ radius = 8.0 damage = 16 kind = 3 -[sub_resource type="Resource" id="Resource_a0mrn"] -script = ExtResource("1_7m6h6") +[sub_resource type="Resource" id="Resource_4sp1g"] +script = ExtResource("2_f5abt") name = "Sliding Lattice" enter_at_hp_fraction = 0.72 telegraph_ticks = 50 -emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_0452f"), SubResource("Resource_1p7x0")]) +emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_txml3"), SubResource("Resource_k1e8f")]) -[sub_resource type="Resource" id="Resource_pfjxl"] -script = ExtResource("6_v02no") +[sub_resource type="Resource" id="Resource_aldxl"] +script = ExtResource("7_bbcbd") arms = 3 bullets_per_arm = 2 arm_spacing = 26.0 @@ -75,8 +85,8 @@ speed = 165.0 lifetime = 400 damage = 13 -[sub_resource type="Resource" id="Resource_txml3"] -script = ExtResource("4_tmo3e") +[sub_resource type="Resource" id="Resource_3fm5w"] +script = ExtResource("5_v02no") count = 7 spread_deg = 40.0 jitter_deg = 3.0 @@ -87,8 +97,8 @@ speed = 195.0 radius = 6.0 kind = 2 -[sub_resource type="Resource" id="Resource_k1e8f"] -script = ExtResource("3_f5abt") +[sub_resource type="Resource" id="Resource_20c8f"] +script = ExtResource("4_rswgd") count = 18 spin_per_shot_deg = -14.0 start_tick = 240 @@ -97,16 +107,16 @@ interval = 60 speed = 105.0 lifetime = 420 -[sub_resource type="Resource" id="Resource_4sp1g"] -script = ExtResource("1_7m6h6") +[sub_resource type="Resource" id="Resource_ymr56"] +script = ExtResource("2_f5abt") name = "Reaping Arms" enter_at_hp_fraction = 0.42 loop_ticks = 720 damage_taken_mult = 1.15 -emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_pfjxl"), SubResource("Resource_txml3"), SubResource("Resource_k1e8f")]) +emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_aldxl"), SubResource("Resource_3fm5w"), SubResource("Resource_20c8f")]) -[sub_resource type="Resource" id="Resource_aldxl"] -script = ExtResource("3_f5abt") +[sub_resource type="Resource" id="Resource_7xac7"] +script = ExtResource("4_rswgd") count = 5 spin_per_shot_deg = 31.0 muzzle_offset = 46.0 @@ -116,8 +126,8 @@ turn_deg = 0.55 lifetime = 400 damage = 15 -[sub_resource type="Resource" id="Resource_3fm5w"] -script = ExtResource("5_rswgd") +[sub_resource type="Resource" id="Resource_3n6oq"] +script = ExtResource("6_x6ufy") direction = 2 count = 16 start_tick = 40 @@ -127,8 +137,8 @@ radius = 8.0 damage = 18 kind = 3 -[sub_resource type="Resource" id="Resource_20c8f"] -script = ExtResource("4_tmo3e") +[sub_resource type="Resource" id="Resource_8ppic"] +script = ExtResource("5_v02no") count = 3 spread_deg = 10.0 start_tick = 100 @@ -138,20 +148,21 @@ radius = 5.0 damage = 16 kind = 2 -[sub_resource type="Resource" id="Resource_ymr56"] -script = ExtResource("1_7m6h6") +[sub_resource type="Resource" id="Resource_yqxmm"] +script = ExtResource("2_f5abt") name = "Collapse" enter_at_hp_fraction = 0.16 loop_ticks = 420 telegraph_ticks = 60 damage_taken_mult = 1.3 -emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_aldxl"), SubResource("Resource_3fm5w"), SubResource("Resource_20c8f")]) +emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_7xac7"), SubResource("Resource_3n6oq"), SubResource("Resource_8ppic")]) [resource] -script = ExtResource("7_x6ufy") +script = ExtResource("8_axg03") id = &"warden" display_name = "Warden of the Fold" max_hp = 3600 radius = 42.0 spawn_pos = Vector2(0, -150) -phases = Array[ExtResource("1_7m6h6")]([SubResource("Resource_s17mk"), SubResource("Resource_a0mrn"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56")]) +phases = Array[ExtResource("2_f5abt")]([SubResource("Resource_pfjxl"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56"), SubResource("Resource_yqxmm")]) +loot = Array[ExtResource("1_b0fvw")]([SubResource("Resource_s17mk"), SubResource("Resource_0452f")]) diff --git a/resources/enemies/drifter.tres b/resources/enemies/drifter.tres index 4377a1c..76bcb46 100644 --- a/resources/enemies/drifter.tres +++ b/resources/enemies/drifter.tres @@ -1,11 +1,12 @@ [gd_resource type="Resource" script_class="EnemyDef" format=3] -[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_801wv"] -[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_bjhtk"] -[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_e6oj1"] +[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_bjhtk"] +[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_e6oj1"] +[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_vpd04"] +[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_k36hi"] -[sub_resource type="Resource" id="Resource_vpd04"] -script = ExtResource("2_bjhtk") +[sub_resource type="Resource" id="Resource_rsrg2"] +script = ExtResource("2_e6oj1") count = 3 spread_deg = 20.0 interval = 120 @@ -13,10 +14,16 @@ radius = 6.0 lifetime = 240 damage = 10 +[sub_resource type="Resource" id="Resource_lnbnj"] +script = ExtResource("3_vpd04") +item = &"health_potion" +chance = 0.08 + [resource] -script = ExtResource("3_e6oj1") +script = ExtResource("4_k36hi") id = &"drifter" display_name = "Drifter" speed = 55.0 -emitters = Array[ExtResource("1_801wv")]([SubResource("Resource_vpd04")]) +emitters = Array[ExtResource("1_bjhtk")]([SubResource("Resource_rsrg2")]) pattern_loop_ticks = 120 +loot = Array[ExtResource("3_vpd04")]([SubResource("Resource_lnbnj")]) diff --git a/resources/enemies/dummy.tres b/resources/enemies/dummy.tres index 8730394..20de167 100644 --- a/resources/enemies/dummy.tres +++ b/resources/enemies/dummy.tres @@ -1,10 +1,11 @@ [gd_resource type="Resource" script_class="EnemyDef" format=3] -[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_ajtny"] -[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="2_i7w5w"] +[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_i7w5w"] +[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="2_74ern"] +[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_2tv1l"] [resource] -script = ExtResource("2_i7w5w") +script = ExtResource("3_2tv1l") id = &"dummy" display_name = "Target Dummy" max_hp = 100000 diff --git a/resources/enemies/stalker.tres b/resources/enemies/stalker.tres index e5cc5b3..3f93463 100644 --- a/resources/enemies/stalker.tres +++ b/resources/enemies/stalker.tres @@ -1,11 +1,12 @@ [gd_resource type="Resource" script_class="EnemyDef" format=3] -[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_t1lyl"] -[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_e7xon"] -[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_ehenj"] +[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_e7xon"] +[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_ehenj"] +[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_tpjo4"] +[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_yp71e"] -[sub_resource type="Resource" id="Resource_tpjo4"] -script = ExtResource("2_e7xon") +[sub_resource type="Resource" id="Resource_oeksl"] +script = ExtResource("2_ehenj") spread_deg = 62.0 muzzle_offset = 10.0 interval = 45 @@ -15,8 +16,13 @@ lifetime = 18 damage = 14 kind = 3 +[sub_resource type="Resource" id="Resource_ep81w"] +script = ExtResource("3_tpjo4") +item = &"health_potion" +chance = 0.08 + [resource] -script = ExtResource("3_ehenj") +script = ExtResource("4_yp71e") id = &"stalker" display_name = "Stalker" max_hp = 30 @@ -25,5 +31,6 @@ move = 3 speed = 95.0 retarget_interval = 20 visual = 2 -emitters = Array[ExtResource("1_t1lyl")]([SubResource("Resource_tpjo4")]) +emitters = Array[ExtResource("1_e7xon")]([SubResource("Resource_oeksl")]) pattern_loop_ticks = 45 +loot = Array[ExtResource("3_tpjo4")]([SubResource("Resource_ep81w")]) diff --git a/resources/enemies/turret.tres b/resources/enemies/turret.tres index 27ed137..76a86d5 100644 --- a/resources/enemies/turret.tres +++ b/resources/enemies/turret.tres @@ -1,18 +1,24 @@ [gd_resource type="Resource" script_class="EnemyDef" format=3] -[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_asksf"] -[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="2_7m5d5"] -[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_5udip"] +[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_7m5d5"] +[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="2_5udip"] +[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_hc01i"] +[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_6rb75"] -[sub_resource type="Resource" id="Resource_hc01i"] -script = ExtResource("2_7m5d5") +[sub_resource type="Resource" id="Resource_kv0ny"] +script = ExtResource("2_5udip") count = 10 spin_per_shot_deg = 18.0 interval = 150 speed = 130.0 +[sub_resource type="Resource" id="Resource_fttpl"] +script = ExtResource("3_hc01i") +item = &"health_potion" +chance = 0.08 + [resource] -script = ExtResource("3_5udip") +script = ExtResource("4_6rb75") id = &"turret" display_name = "Turret" max_hp = 70 @@ -20,5 +26,6 @@ radius = 16.0 move = 0 speed = 0.0 visual = 1 -emitters = Array[ExtResource("1_asksf")]([SubResource("Resource_hc01i")]) +emitters = Array[ExtResource("1_7m5d5")]([SubResource("Resource_kv0ny")]) pattern_loop_ticks = 150 +loot = Array[ExtResource("3_hc01i")]([SubResource("Resource_fttpl")]) diff --git a/src/actors/boss/boss_def.gd b/src/actors/boss/boss_def.gd index 96bc0e4..7aa45d6 100644 --- a/src/actors/boss/boss_def.gd +++ b/src/actors/boss/boss_def.gd @@ -12,6 +12,10 @@ extends Resource @export var stationary: bool = true @export var spawn_pos := Vector2(0.0, -140.0) @export var phases: Array[BossPhase] = [] +## What the kill leaves behind. Bosses are the guaranteed source: a run that +## reaches the end should always be worth something, so unlike trash loot these +## entries are normally chance 1.0. +@export var loot: Array[LootDrop] = [] ## Index of the phase that matches [param hp_fraction]. Later entries win, so a diff --git a/src/actors/enemies/enemy_def.gd b/src/actors/enemies/enemy_def.gd index 1d5fbac..2abf368 100644 --- a/src/actors/enemies/enemy_def.gd +++ b/src/actors/enemies/enemy_def.gd @@ -36,3 +36,6 @@ enum Move { @export var emitters: Array[BulletEmitter] = [] ## The emitter timeline wraps at this many ticks. @export var pattern_loop_ticks: int = 240 +## What this enemy may leave behind. Rolled once per entry on death, against +## the world's own RNG. Empty for anything that should drop nothing. +@export var loot: Array[LootDrop] = [] diff --git a/src/actors/items/item_def.gd b/src/actors/items/item_def.gd new file mode 100644 index 0000000..fbacc9c --- /dev/null +++ b/src/actors/items/item_def.gd @@ -0,0 +1,30 @@ +class_name ItemDef +extends Resource +## Data-only description of an item, in the same spirit as [EnemyDef]: adding an +## item is a table entry in [Items], never a branch in the simulation. +## +## Items are deliberately thin. A slot holds an id and nothing else -- no +## charges, no durability, no stack count -- because everything the game +## currently needs is expressible as "one id per slot", and the wire format, +## the persistence record and the UI all get simpler for it. When something +## needs a count, add it here rather than teaching four layers about it. + +enum Effect { + ## Consumed and does nothing. Not a placeholder: the boss ration exists to + ## prove the player-instanced loot path works end to end, and it can only + ## do that if using it is a real, observable transaction. + NONE, + ## Restores [member effect_value] percent of MAXIMUM health, so a potion + ## keeps its value at level 15 instead of becoming a rounding error. + HEAL, +} + +@export var id: StringName = &"item" +@export var display_name: String = "Item" +@export var effect: Effect = Effect.NONE +@export var effect_value: float = 0.0 +## Index into the renderer's item icon table. Same idea as [member +## EnemyDef.visual]: the simulation never learns that art exists. +@export var visual: int = 0 +## HUD tint. View-only, kept here so one table describes the whole item. +@export var tint := Color(0.85, 0.85, 0.9) diff --git a/src/actors/items/item_def.gd.uid b/src/actors/items/item_def.gd.uid new file mode 100644 index 0000000..34c7150 --- /dev/null +++ b/src/actors/items/item_def.gd.uid @@ -0,0 +1 @@ +uid://t2fhc2hd1575 diff --git a/src/actors/items/loot_drop.gd b/src/actors/items/loot_drop.gd new file mode 100644 index 0000000..278243d --- /dev/null +++ b/src/actors/items/loot_drop.gd @@ -0,0 +1,28 @@ +class_name LootDrop +extends Resource +## One entry in an enemy's or boss's loot table. +## +## [member instanced] is the interesting field, and it is the reason loot has +## two visibilities rather than one: +## +## - false -- a single entity in the instance that everyone can see and the +## first to reach takes. This is the default, and it is what makes loot a +## thing a party negotiates over. +## - true -- one entity per eligible player, each visible only to its owner. +## Nobody competes, nobody is denied. The server filters these out of every +## other peer's snapshot, so it is an interest-management rule and not merely +## a UI convention: a modified client is not told the others exist. + +@export var item: StringName = &"" +## Probability in [0, 1], rolled once per kill against the world's own RNG. +@export var chance: float = 1.0 +@export var instanced: bool = false + + +static func make(item_id: StringName, drop_chance: float, + player_instanced: bool = false) -> LootDrop: + var d := LootDrop.new() + d.item = item_id + d.chance = drop_chance + d.instanced = player_instanced + return d diff --git a/src/actors/items/loot_drop.gd.uid b/src/actors/items/loot_drop.gd.uid new file mode 100644 index 0000000..13e3ce2 --- /dev/null +++ b/src/actors/items/loot_drop.gd.uid @@ -0,0 +1 @@ +uid://de021jelm6hcg diff --git a/src/content/content.gd b/src/content/content.gd index bb96908..d285a6a 100644 --- a/src/content/content.gd +++ b/src/content/content.gd @@ -14,6 +14,10 @@ const ENEMY_STALKER := &"stalker" const ENEMY_DUMMY := &"dummy" const BOSS_WARDEN := &"warden" +## How often an ordinary enemy leaves a potion. "Rare" is the design brief: a +## dungeon run should be survivable on what it hands you, but never comfortably. +const TRASH_POTION_CHANCE := 0.08 + static func enemy(id: StringName) -> EnemyDef: match id: @@ -57,6 +61,7 @@ static func drifter() -> EnemyDef: fan.lifetime = 240 fan.kind = SimConfig.KIND_ORB d.emitters = [fan] + d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)] return d @@ -83,6 +88,7 @@ static func turret() -> EnemyDef: ring.lifetime = 300 ring.kind = SimConfig.KIND_ORB d.emitters = [ring] + d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)] return d @@ -120,6 +126,7 @@ static func stalker() -> EnemyDef: lunge.muzzle_offset = 10.0 lunge.kind = SimConfig.KIND_HEAVY d.emitters = [lunge] + d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)] return d @@ -149,6 +156,14 @@ static func warden() -> BossDef: b.stationary = true b.spawn_pos = Vector2(0.0, -150.0) b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()] + # Guaranteed, and deliberately one of each visibility. The potion is shared, + # so a party still has something to divide up; the ration is instanced, so + # every player who survived the fight leaves with the trophy and nobody has + # to race for it. Between them they exercise both loot paths on every kill. + b.loot = [ + LootDrop.make(Items.HEALTH_POTION, 1.0), + LootDrop.make(Items.WARDENS_RATION, 1.0, true), + ] return b diff --git a/src/content/items.gd b/src/content/items.gd new file mode 100644 index 0000000..44c7d9a --- /dev/null +++ b/src/content/items.gd @@ -0,0 +1,81 @@ +class_name Items +extends RefCounted +## Every item in the game, defined in code for the same reasons [Content] is. +## +## [constant ORDER] is load-bearing: an item's position in it is the byte that +## rides the snapshot and every item event. Item ids are strings and a slot is +## sent 20 times a second per player, so the wire carries the index instead. +## Append to the list, never reorder it -- and if you do reorder it, bump +## [constant Protocol.VERSION], because every client would otherwise decode a +## potion as a ration. + +const NONE := &"" +const HEALTH_POTION := &"health_potion" +const WARDENS_RATION := &"wardens_ration" + +## Wire order. Index 0 is reserved for "empty slot" / "no item", so an id's +## wire value is its position here plus one. +const ORDER: Array[StringName] = [ + HEALTH_POTION, + WARDENS_RATION, +] + + +static func get_def(id: StringName) -> ItemDef: + match id: + HEALTH_POTION: return health_potion() + WARDENS_RATION: return wardens_ration() + return null + + +## Wire value for an id. 0 for an empty slot or an id this build does not know, +## which is the safe direction: an unknown item reads as nothing rather than as +## whatever happens to sit at that index. +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 NONE + return ORDER[index - 1] + + +static func display_name_of(id: StringName) -> String: + var def := get_def(id) + return def.display_name if def != null else "" + + +# --- The items -------------------------------------------------------------- + +## The only item with an effect. Percentage-based so it stays meaningful across +## the whole level range -- a flat 40 hp would be a full heal at level 1 and +## noise at level 15. +static func health_potion() -> ItemDef: + var d := ItemDef.new() + d.id = HEALTH_POTION + d.display_name = "Health Potion" + d.effect = ItemDef.Effect.HEAL + d.effect_value = 40.0 + d.visual = 0 + d.tint = Color(0.95, 0.4, 0.42) + return d + + +## Dropped by every boss, one per player who was alive for the kill, and does +## nothing whatsoever when used. +## +## It is not filler. Player-instanced loot is a different code path from shared +## loot -- a separate entity per owner, filtered per peer on the wire -- and the +## cheapest way to keep that path honest is to have something in the game that +## uses it every single boss kill. A trophy nobody has to fight over is exactly +## the right shape for that job. +static func wardens_ration() -> ItemDef: + var d := ItemDef.new() + d.id = WARDENS_RATION + d.display_name = "Warden's Ration" + d.effect = ItemDef.Effect.NONE + d.visual = 1 + d.tint = Color(0.95, 0.82, 0.35) + return d diff --git a/src/content/items.gd.uid b/src/content/items.gd.uid new file mode 100644 index 0000000..775ff32 --- /dev/null +++ b/src/content/items.gd.uid @@ -0,0 +1 @@ +uid://d07ue5tca3xt8 diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 8615ed2..0b7b402 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -89,6 +89,25 @@ const INPUT_MAX_LEAD := 40 ## backstop that makes the failure above self-healing whatever its cause. const INPUT_ACK_STALL_LIMIT := 8 +# --- Inventory and loot ----------------------------------------------------- +## Slots a character carries. Small enough to sit on screen permanently, which +## is the whole design: an inventory you have to open is a menu, and a menu in +## a bullet hell is a death. Growing this is a one-line change here -- the wire +## format, the HUD and the persistence record all read it. +const INVENTORY_SLOTS := 4 +## How close you have to stand to pick something up. Comfortably larger than +## PLAYER_RADIUS so walking "onto" an item is enough; well under the distance +## at which you could grab loot you cannot see. +const LOOT_PICKUP_RADIUS := 34.0 +## Radius of the ring player-instanced boss drops are laid out on. Each player +## only ever sees their own, so this is purely so a debug view of all of them +## is legible rather than one pile. +const LOOT_INSTANCED_SPREAD := 34.0 +## Hard ceiling on ground loot in one instance. Dungeons are short-lived, so in +## practice this only ever bites in the hub, where players can drop things and +## nothing ever closes to clean up. Oldest goes first. +const MAX_LOOT_PER_INSTANCE := 64 + # --- Emergency escape ------------------------------------------------------- const ESCAPE_CHANNEL_TICKS := 60 # 1 second ## Taking damage does NOT interrupt the channel. It used to, which sounds like diff --git a/src/meta/character.gd b/src/meta/character.gd index de6002c..e7135b9 100644 --- a/src/meta/character.gd +++ b/src/meta/character.gd @@ -23,6 +23,10 @@ var total_xp: int = 0 var active: bool = true var created_unix: int = 0 var died_unix: int = 0 +## Carried items, one id per slot, [constant Items.NONE] where empty. Stored on +## the character rather than on the session so that swapping in the hub swaps +## bags, and so a server restart does not quietly confiscate everyone's potions. +var inventory: Array[StringName] = [] ## Suggested names, offered when creating a character so the field is never @@ -47,6 +51,11 @@ static func random_name(rng: RandomNumberGenerator) -> String: ] +func _init() -> void: + inventory.resize(SimConfig.INVENTORY_SLOTS) + inventory.fill(Items.NONE) + + static func create(character_name: String, rng: RandomNumberGenerator) -> Character: var c := Character.new() # Random-but-readable: full saturation and high value, so two characters are @@ -107,9 +116,20 @@ func to_dict() -> Dictionary: "active": active, "created": created_unix, "died": died_unix, + # Written as ids rather than indices: a save file has to survive + # Items.ORDER being appended to, and a human editing it should be able + # to tell what a character is carrying. + "inventory": _inventory_ids(), } +func _inventory_ids() -> Array: + var out := [] + for item in inventory: + out.append(String(item)) + return out + + ## Tolerant of missing keys so an older save file still loads: a character that ## has lost a field is far better than an account that will not open. static func from_dict(d: Dictionary) -> Character: @@ -123,4 +143,18 @@ static func from_dict(d: Dictionary) -> Character: c.active = bool(d.get("active", true)) c.created_unix = int(d.get("created", 0)) c.died_unix = int(d.get("died", 0)) + # Unknown ids decay to empty rather than to a wrong item -- a save written + # by a build with an item this one has never heard of must still load. + var carried: Array[StringName] = [] + for raw in d.get("inventory", []): + var carried_id := StringName(String(raw)) + carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE) + c.set_inventory(carried) return c + + +## Replace the whole inventory, padded or trimmed to the current slot count. +func set_inventory(items: Array[StringName]) -> void: + inventory.resize(SimConfig.INVENTORY_SLOTS) + for i in SimConfig.INVENTORY_SLOTS: + inventory[i] = items[i] if i < items.size() else Items.NONE diff --git a/src/meta/character_store.gd b/src/meta/character_store.gd index c26a701..79a2e9e 100644 --- a/src/meta/character_store.gd +++ b/src/meta/character_store.gd @@ -105,6 +105,17 @@ func retire_character(account_id: int, character_id: String) -> void: save() +## Write a character's carried items back to disk. Separate from grant_xp +## rather than folded into a general "save this character", so the one caller +## reads as what it is. +func set_inventory(account_id: int, character_id: String, items: Array[StringName]) -> void: + var c := get_character(account_id, character_id) + if c == null: + return + c.set_inventory(items) + save() + + 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 ba31e54..7b80ce0 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -15,6 +15,11 @@ signal local_hit(damage: int) signal shot_fired signal enemy_died signal boss_died +## Item transactions, straight from server events. Carry the item id so the +## view can name what happened without guessing from the inventory diff. +signal item_picked_up(item: StringName) +signal item_used(item: StringName) +signal item_dropped(item: StringName) ## The account's character roster changed: created, selected, levelled or died. signal characters_changed signal select_failed(reason: String) @@ -47,6 +52,10 @@ var my_max_hp: int = SimConfig.PLAYER_MAX_HP ## Lifetime experience, straight from the snapshot so the bar moves per kill ## rather than per roster message. var my_total_xp: int = 0 +## Carried items as wire indices (0 = empty slot). Replaced wholesale by every +## snapshot, so it can never drift from what the server thinks you have -- +## there is deliberately no local "I picked that up" optimism here. +var my_inventory: Array[int] = [] var my_alive: bool = true var my_escape: float = 0.0 var my_escaping: bool = false @@ -183,7 +192,23 @@ func _sample_input() -> InputFrame: buttons |= InputFrame.BTN_ESCAPE if Input.is_action_pressed("interact"): buttons |= InputFrame.BTN_INTERACT - return InputFrame.make(input_tick, move, aim, buttons) + # Number keys use a slot; shift-number drops it. The bit is sent for as long + # as the key is held and the server takes the leading edge, so a stuck or + # repeated packet cannot spend more than one item. + var slot := held_slot() + if slot >= 0: + buttons |= InputFrame.BTN_DROP if Input.is_key_pressed(KEY_SHIFT) \ + else InputFrame.BTN_USE + return InputFrame.make(input_tick, move, aim, buttons, maxi(slot, 0)) + + +## Which inventory slot key is down, or -1. Lowest wins, so pressing 1 while 2 +## is held reads as "now slot 1" rather than as nothing. +func held_slot() -> int: + for i in SimConfig.INVENTORY_SLOTS: + if Input.is_action_pressed("use_slot_%d" % (i + 1)): + return i + return -1 ## Scripted input so `tools/smoke.sh` can play the game with no display: orbit @@ -202,9 +227,20 @@ func _bot_input() -> InputFrame: buttons |= InputFrame.BTN_INTERACT # Walk onto the portal instead of orbiting, or interact never lands. move = (portal_pos - predicted_pos).normalized() + var slot := 0 + if instance_kind == Protocol.InstanceKind.DUNGEON: + # Grab at whatever is underfoot and occasionally drink, so the item + # messages are exercised over a real socket by the smoke test. Bots are + # poor shots and rarely produce loot, so this is coverage of the wire + # format rather than of the drop rules -- tools/diag_loot.tscn covers + # those. + if _bot_tick % 90 < 6: + buttons |= InputFrame.BTN_INTERACT + if _bot_tick % 150 == 0: + buttons |= InputFrame.BTN_USE if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900: buttons |= InputFrame.BTN_ESCAPE - return InputFrame.make(input_tick, move, aim, buttons) + return InputFrame.make(input_tick, move, aim, buttons, slot) # --- Server messages -------------------------------------------------------- @@ -325,6 +361,7 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, request_escape = false request_respawn = false my_respawn_wait = 0.0 + my_inventory = [] cleared_countdown = Protocol.COUNTDOWN_NONE GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]]) instance_changed.emit() @@ -336,6 +373,7 @@ func on_snapshot(data: PackedByteArray) -> void: if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]): return # stale or duplicate; unreliable channel, newest wins cleared_countdown = int(snap["cleared_countdown"]) + my_inventory = snap["inventory"] snap_prev = snap_curr snap_curr = snap _interp = 0.0 @@ -435,6 +473,18 @@ func on_events(data: PackedByteArray) -> void: enemy_died.emit() SimEvent.Type.BOSS_DIED: boss_died.emit() + SimEvent.Type.ITEM_PICKED_UP: + if int(ev["peer"]) == my_peer: + item_picked_up.emit(ev["item"]) + hud_dirty.emit() + SimEvent.Type.ITEM_USED: + if int(ev["peer"]) == my_peer: + item_used.emit(ev["item"]) + hud_dirty.emit() + SimEvent.Type.ITEM_DROPPED: + if int(ev["peer"]) == my_peer: + item_dropped.emit(ev["item"]) + hud_dirty.emit() _: pass @@ -451,6 +501,40 @@ func enemies() -> Array[Dictionary]: return _interpolated("enemies", "id", []) +## Ground loot the server has told us about. Not interpolated -- items do not +## move -- and never filtered here: what arrives is already exactly what this +## player is allowed to see. +func ground_loot() -> Array: + if snap_curr.is_empty(): + return [] + return snap_curr["loot"] + + +## The item that pressing interact would pick up, or an empty dictionary. Purely +## for the prompt: the server does this same search for itself and does not care +## what the client concluded. +func loot_in_reach() -> Dictionary: + var best := {} + var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS + for l: Dictionary in ground_loot(): + var d: float = predicted_pos.distance_squared_to(l["pos"]) + if d <= best_d: + best_d = d + best = l + return best + + +## True when every slot is taken, so the HUD can explain why a pickup did +## nothing rather than looking broken. +func inventory_full() -> bool: + if my_inventory.is_empty(): + return false + for index in my_inventory: + if index == 0: + return false + return true + + func boss_state() -> Dictionary: if snap_curr.is_empty() or snap_curr.get("boss") == null: return {} diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index 704ff86..b6879ab 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -103,6 +103,32 @@ static func encode_snapshot(world: SimWorld, b.put_float(world.boss.pos.y) b.put_u32(maxi(world.boss.hp, 0)) b.put_u8(clampi(world.boss.phase_index, 0, 255)) + + # Only the observer's own bag. Nobody needs to see what a party member is + # carrying, and not sending it means there is nothing to leak. + for i in SimConfig.INVENTORY_SLOTS: + var carried := Items.NONE + if observer != null and i < observer.inventory.size(): + carried = observer.inventory[i] + b.put_u8(Items.index_of(carried)) + + # Ground loot. Player-instanced items are filtered here rather than hidden + # in the client: a peer is never told that another player's copy exists, so + # a modified client has nothing to reveal. + var visible_loot: Array[SimLoot] = [] + for l in world.loot.values(): + if observer != null: + if not l.visible_to(for_peer): + continue + if eye.distance_squared_to(l.pos) > cull_sq: + continue + visible_loot.append(l) + b.put_u16(mini(visible_loot.size(), 65535)) + for l in visible_loot: + b.put_u32(l.id) + b.put_float(l.pos.x) + b.put_float(l.pos.y) + b.put_u8(Items.index_of(l.item)) return b.data_array @@ -114,6 +140,7 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary: "tick": b.get_u32(), "cleared_countdown": b.get_u8(), "players": [], "enemies": [], "boss": null, + "loot": [], "inventory": [], } var pcount := b.get_u8() @@ -149,6 +176,19 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary: "hp": b.get_u32(), "phase": b.get_u8(), } + + var inventory: Array[int] = [] + for _i in SimConfig.INVENTORY_SLOTS: + inventory.append(b.get_u8()) + snap["inventory"] = inventory + + var lcount := b.get_u16() + for _i in lcount: + snap["loot"].append({ + "id": b.get_u32(), + "pos": Vector2(b.get_float(), b.get_float()), + "item": b.get_u8(), + }) return snap @@ -201,6 +241,11 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \ SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: body.put_u32(ev["peer"]) + SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \ + SimEvent.Type.ITEM_DROPPED: + body.put_u32(ev["peer"]) + # By index, like everywhere else on the wire. See Items.ORDER. + body.put_u8(Items.index_of(ev["item"])) SimEvent.Type.PLAYER_RESPAWNED: body.put_u32(ev["peer"]) body.put_float(ev["pos"].x) @@ -252,6 +297,10 @@ static func decode_events(data: PackedByteArray) -> Dictionary: SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \ SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: ev["peer"] = b.get_u32() + SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \ + SimEvent.Type.ITEM_DROPPED: + ev["peer"] = b.get_u32() + ev["item"] = Items.by_index(b.get_u8()) SimEvent.Type.PLAYER_RESPAWNED: ev["peer"] = b.get_u32() ev["pos"] = Vector2(b.get_float(), b.get_float()) @@ -393,6 +442,8 @@ static func encode_characters(chars: Array[Character], selected: String) -> Pack b.put_u16(clampi(c.max_hp(), 1, 65535)) b.put_u8(1 if c.active else 0) b.put_u32(c.colour.to_rgba32()) + for i in SimConfig.INVENTORY_SLOTS: + b.put_u8(Items.index_of(c.inventory[i] if i < c.inventory.size() else Items.NONE)) return b.data_array @@ -427,8 +478,8 @@ static func decode_characters(data: PackedByteArray) -> Dictionary: return {"selected": selected, "characters": out} var count := b.get_u8() # Bytes each entry needs after its two strings: level, xp, progress, - # max_hp, active, colour. - var fixed := 1 + 4 + 1 + 2 + 1 + 4 + # max_hp, active, colour, then one byte per inventory slot. + var fixed := 1 + 4 + 1 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS for _i in count: var id := _safe_utf8(b) var display := _safe_utf8(b) @@ -443,5 +494,13 @@ static func decode_characters(data: PackedByteArray) -> Dictionary: "max_hp": b.get_u16(), "active": b.get_u8() == 1, "colour": Color.hex(b.get_u32()), + "inventory": _read_inventory(b), }) return {"selected": selected, "characters": out} + + +static func _read_inventory(b: StreamPeerBuffer) -> Array[int]: + var out: Array[int] = [] + for _i in SimConfig.INVENTORY_SLOTS: + out.append(b.get_u8()) + return out diff --git a/src/net/protocol.gd b/src/net/protocol.gd index f81f420..ed5ed4a 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -13,7 +13,11 @@ extends RefCounted ## 5: handshake carries an auth ticket instead of a bare name; added character ## list/select/create messages, per-player max health and colour in the ## snapshot. -const VERSION := 5 +## 6: inventory and loot. The snapshot gained the observer's own inventory and +## the ground-loot list, the input frame gained a slot byte, and three item +## events were appended. Every one of those changes the byte layout of a +## message both ends parse positionally. +const VERSION := 6 const DEFAULT_PORT := 27015 const MAX_CLIENTS := 32 diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index 46617ae..2e9e795 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -97,6 +97,12 @@ func _dispatch_events(inst: Instance) -> void: _award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", "")))) SimEvent.Type.BOSS_DIED: _award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", "")))) + SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \ + SimEvent.Type.ITEM_DROPPED: + # The simulation moved items between the ground and a bag; the + # store is what makes that survive a restart. Same division of + # labour as experience: the world decides, this layer banks it. + _persist_inventory(inst, int(ev["peer"])) SimEvent.Type.PLAYER_DIED: # Deferred like the transfers below: the payload has not been # sent yet, and a player must still receive news of its own @@ -436,6 +442,23 @@ func _award_kill(inst: Instance, amount: int) -> void: _grant_xp(peer, amount) +## Copy a player's bag back onto the character record it belongs to. +## +## Called on every item transaction rather than on a timer, because the whole +## point of persisting an inventory is that a crash between "picked it up" and +## "wrote it down" must not be a way to lose an item -- or, far worse, a way to +## duplicate one. +func _persist_inventory(inst: Instance, 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 p: SimPlayer = inst.world.players.get(peer_id) + if p == null: + return + store.set_inventory(account, character_id, p.inventory) + + func _grant_xp(peer_id: int, amount: int) -> void: var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) var character_id: String = peer_characters.get(peer_id, "") diff --git a/src/sim/input_frame.gd b/src/sim/input_frame.gd index 58b70ba..c2379de 100644 --- a/src/sim/input_frame.gd +++ b/src/sim/input_frame.gd @@ -2,26 +2,46 @@ class_name InputFrame extends RefCounted ## One tick of player intent. This is the only thing a client is allowed to tell ## the server about its own state: no positions, no hits, no damage. +## +## Item actions live here rather than in their own reliable message, which is +## worth being explicit about. Using or dropping an item is a thing you do +## *during* a fight, so it has to be ordered against your movement on the same +## tick, and it has to be as cheap to reject as a movement vector. Riding the +## input stream gets all of that for free: the redundancy that covers a dropped +## packet, the replay guard on [member SimPlayer.last_input_tick], and a natural +## rate limit of one action per tick. A separate "use item" RPC would have +## needed every one of those bolted on again. const BTN_FIRE := 1 const BTN_ESCAPE := 2 const BTN_INTERACT := 4 +## Use the item in [member slot]. Edge-triggered by the server, so holding the +## key spends one potion and not sixty. +const BTN_USE := 8 +## Drop the item in [member slot] on the ground, where anyone can take it. +const BTN_DROP := 16 -## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons. -const SIZE := 9 +## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons, u8 slot. +const SIZE := 10 var tick: int = 0 var move := Vector2.ZERO var aim: float = 0.0 var buttons: int = 0 +## Which inventory slot BTN_USE / BTN_DROP refer to. Meaningless without one of +## those bits set; the server clamps it before use, so a hostile value indexes +## nothing. +var slot: int = 0 -static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int) -> InputFrame: +static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int, + p_slot: int = 0) -> InputFrame: var f := InputFrame.new() f.tick = p_tick f.move = p_move f.aim = p_aim f.buttons = p_buttons + f.slot = p_slot return f @@ -37,6 +57,7 @@ func write(buf: StreamPeerBuffer) -> void: buf.put_8(clampi(roundi(move.y * 100.0), -100, 100)) buf.put_u16(wrapi(roundi(aim / TAU * 65536.0), 0, 65536)) buf.put_u8(buttons & 0xFF) + buf.put_u8(slot & 0xFF) static func read(buf: StreamPeerBuffer) -> InputFrame: @@ -45,4 +66,5 @@ static func read(buf: StreamPeerBuffer) -> InputFrame: f.move = Vector2(float(buf.get_8()) / 100.0, float(buf.get_8()) / 100.0) f.aim = float(buf.get_u16()) / 65536.0 * TAU f.buttons = buf.get_u8() + f.slot = buf.get_u8() return f diff --git a/src/sim/sim_events.gd b/src/sim/sim_events.gd index 0a39aa4..155c684 100644 --- a/src/sim/sim_events.gd +++ b/src/sim/sim_events.gd @@ -27,4 +27,11 @@ enum Type { ESCAPE_CANCELLED, ## peer ESCAPE_COMPLETED, ## peer -- the instance layer acts on this PORTAL_USED, ## peer -- the instance layer acts on this + ## Item transactions. Appended at the end of the enum on purpose: inserting + ## mid-list shifts the wire value of everything after it, which is what made + ## PLAYER_FIRED a protocol break. All three carry (peer, item) and all three + ## tell ServerRuntime the character's inventory needs persisting. + ITEM_PICKED_UP, ## peer, item + ITEM_USED, ## peer, item + ITEM_DROPPED, ## peer, item } diff --git a/src/sim/sim_loot.gd b/src/sim/sim_loot.gd new file mode 100644 index 0000000..e63b46f --- /dev/null +++ b/src/sim/sim_loot.gd @@ -0,0 +1,25 @@ +class_name SimLoot +extends RefCounted +## An item lying on the ground in one [SimWorld]. +## +## Ground loot does not move, so unlike bullets it needs no event stream: it +## rides the snapshot alongside enemies and is re-sent 20 times a second. That +## also means a lost packet costs nothing, which a spawn-once event could not +## claim. + +var id: int = 0 +var item: StringName = &"" +var pos := Vector2.ZERO +## 0 means world-shared: everyone sees it, the first to reach it takes it. +## Otherwise the ONLY peer that may see or take it -- the server filters it out +## of every other snapshot, so instancing is enforced on the wire and not by +## asking the client to be polite. +var owner_peer: int = 0 +## World tick it appeared, used only to decide what to evict when an instance +## somehow accumulates more loot than it should hold. +var born_tick: int = 0 + + +## Whether [param peer_id] is allowed to see and take this. +func visible_to(peer_id: int) -> bool: + return owner_peer == 0 or owner_peer == peer_id diff --git a/src/sim/sim_loot.gd.uid b/src/sim/sim_loot.gd.uid new file mode 100644 index 0000000..ad3c705 --- /dev/null +++ b/src/sim/sim_loot.gd.uid @@ -0,0 +1 @@ +uid://r142v0ai64i6 diff --git a/src/sim/sim_player.gd b/src/sim/sim_player.gd index 56fe220..9631449 100644 --- a/src/sim/sim_player.gd +++ b/src/sim/sim_player.gd @@ -31,6 +31,17 @@ var regen_carry: float = 0.0 ## Ticks before a downed player may ask to return to the hub. var respawn_lockout: int = 0 +## Carried items, one id per slot, [constant Items.NONE] where empty. Always +## exactly SimConfig.INVENTORY_SLOTS long -- callers index it directly, so it +## must never be short. +var inventory: Array[StringName] = [] +## Buttons and slot from the previous consumed input, so item actions can be +## edge-triggered. Without this, holding the "use" key would drink the whole +## inventory in four ticks. The slot is part of the edge as well: tapping 2 +## while 1 is still held is a second, distinct action. +var prev_buttons: int = 0 +var prev_slot: int = -1 + ## 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 @@ -50,6 +61,11 @@ var held_input: InputFrame = InputFrame.new() var starved_ticks: int = 0 +func _init() -> void: + inventory.resize(SimConfig.INVENTORY_SLOTS) + inventory.fill(Items.NONE) + + func escape_progress() -> float: return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0) @@ -78,7 +94,16 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void: regen_carry = 0.0 fire_cooldown = 0 escape_ticks = 0 + # Cleared, so a button held through a transition registers as a fresh press + # on arrival. Safe rather than merely tolerable: hp is set to max on the + # line above, and a heal at full health is refused, so the only item a held + # key can spend on arrival is one that does nothing anyway. + prev_buttons = 0 + prev_slot = -1 input_queue.clear() + # Inventory deliberately survives. It belongs to the character, not to the + # room: walking into a dungeon with the potions you bought is the entire + # point of carrying them. ## True once the lockout has run out and the hub is available again. @@ -87,6 +112,8 @@ func can_request_respawn() -> bool: ## Adopt a character's stats. Called when a player picks or switches character. +## Inventory comes along: it is stored on the character, so swapping in the hub +## swaps bags too rather than handing one character another's potions. func adopt(c: Character) -> void: character_id = c.id display_name = c.display_name @@ -95,6 +122,53 @@ func adopt(c: Character) -> void: colour = c.colour max_hp = c.max_hp() hp = mini(hp, max_hp) + set_inventory(c.inventory) + + +## 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. +func set_inventory(items: Array[StringName]) -> void: + inventory.resize(SimConfig.INVENTORY_SLOTS) + for i in SimConfig.INVENTORY_SLOTS: + inventory[i] = items[i] if i < items.size() else Items.NONE + + +func free_slot() -> int: + for i in inventory.size(): + if inventory[i] == Items.NONE: + return i + return -1 + + +## Put [param item] in the first free slot. Returns the slot, or -1 when full. +func add_item(item: StringName) -> int: + var at := free_slot() + if at >= 0: + inventory[at] = item + return at + + +## Empty [param slot] and return what was in it, or [constant Items.NONE]. +## Out-of-range indices answer NONE rather than erroring: the index came off +## the wire, so it has to be safe to be nonsense. +func take_slot(slot_index: int) -> StringName: + if slot_index < 0 or slot_index >= inventory.size(): + return Items.NONE + var item := inventory[slot_index] + inventory[slot_index] = Items.NONE + return item + + +## Heal by a percentage of MAXIMUM health, and report whether it did anything. +## Refusing a wasted heal is what stops a potion being consumed at full health. +func heal_percent(percent: float) -> int: + if not alive or hp >= max_hp: + return 0 + var amount := maxi(1, roundi(float(max_hp) * percent / 100.0)) + var before := hp + hp = mini(hp + amount, max_hp) + return hp - before ## One tick of passive healing. Returns true if the visible hit points changed, diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index e1dd68b..ab0533c 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -21,6 +21,9 @@ var rng := RandomNumberGenerator.new() var players: Dictionary[int, SimPlayer] = {} var enemies: Dictionary[int, SimEnemy] = {} var boss: SimBoss = null +## Items lying on the ground, by actor id. Shares the id space with enemies and +## the boss, so nothing has to reason about two kinds of id. +var loot: Dictionary[int, SimLoot] = {} ## Drained by the owner every tick. See [SimEvent]. var events: Array[Dictionary] = [] @@ -83,6 +86,12 @@ func add_player(peer_id: int, display_name: String) -> SimPlayer: func remove_player(peer_id: int) -> void: players.erase(peer_id) + # Loot instanced to this peer goes with them. Nobody else can see or take + # it, so leaving it behind would be an invisible entity the instance carries + # until it closes. + for id in loot.keys(): + if loot[id].owner_peer == peer_id: + loot.erase(id) func spawn_enemy(def: EnemyDef, at: Vector2, phase_offset: int = 0) -> SimEnemy: @@ -179,6 +188,7 @@ func _step_players() -> void: p.fire_cooldown -= 1 var frame := _take_input(p) + var edge := _button_edge(p, frame) if not p.alive: if p.respawn_lockout > 0: @@ -199,7 +209,24 @@ func _step_players() -> void: _step_escape(p, frame) - if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT): + # Item actions are edge-triggered; movement and fire are not. Holding + # the key must spend one potion, and the buttons arrive repeated (the + # client sends the last few frames every tick, and a starved server + # coasts on the last one), so a level-triggered read would empty the + # whole inventory in four ticks. + if edge & InputFrame.BTN_USE: + _use_slot(p, frame.slot) + if edge & InputFrame.BTN_DROP: + _drop_slot(p, frame.slot) + # Pickup shares the interact button with the portal. Loot wins when both + # are in reach, and only for the tick it actually took something -- a + # full inventory must not leave you standing on the portal unable to use + # it. + var took_item := false + if edge & InputFrame.BTN_INTERACT: + took_item = _try_pickup(p) + + if portal_enabled and not took_item and frame.pressed(InputFrame.BTN_INTERACT): if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS: events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id}) @@ -221,6 +248,18 @@ func _take_input(p: SimPlayer) -> InputFrame: return p.held_input +## Buttons newly pressed on this frame, for the actions that must not repeat. +## The slot counts as part of the edge: tapping slot 2 while slot 1 is still +## held is a second, distinct action, not a swallowed one. +func _button_edge(p: SimPlayer, frame: InputFrame) -> int: + var edge := frame.buttons & ~p.prev_buttons + if frame.slot != p.prev_slot: + edge |= frame.buttons & (InputFrame.BTN_USE | InputFrame.BTN_DROP) + p.prev_buttons = frame.buttons + p.prev_slot = frame.slot + return edge + + func _fire_player_shot(p: SimPlayer) -> void: p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id}) @@ -235,6 +274,143 @@ func _fire_player_shot(p: SimPlayer) -> void: SimConfig.KIND_PLAYER_SHOT) +# --- Items and loot --------------------------------------------------------- + +## Radius used to check that a dropped item is not inside a wall. Smaller than +## anything that walks, because loot only has to be reachable, not roomy. +const LOOT_CLEARANCE := 6.0 + + +## Put [param item] on the ground. [param owner_peer] of 0 is world-shared; +## anything else is visible and takeable only by that peer. +func spawn_loot(item: StringName, at: Vector2, owner_peer: int = 0) -> SimLoot: + if Items.get_def(item) == null: + return null + _make_room_for_loot() + var l := SimLoot.new() + l.id = next_actor_id() + l.item = item + l.pos = at + l.owner_peer = owner_peer + l.born_tick = tick + loot[l.id] = l + return l + + +## Keep ground loot bounded. Only the hub can realistically reach the cap -- +## dungeons close and take their litter with them -- so the oldest item is the +## right thing to lose: it is the one that has been ignored the longest. +func _make_room_for_loot() -> void: + while loot.size() >= SimConfig.MAX_LOOT_PER_INSTANCE: + var oldest := -1 + for id in loot: + if oldest < 0 or loot[id].born_tick < loot[oldest].born_tick: + oldest = id + if oldest < 0: + return + loot.erase(oldest) + + +## Roll a loot table and put what it produced on the floor. Called on death, so +## it runs inside hit resolution and uses the world's own RNG -- loot is part of +## the simulation, not something the instance layer sprinkles on afterwards. +func _drop_loot(table: Array[LootDrop], at: Vector2) -> void: + for entry in table: + if entry == null or entry.item == Items.NONE: + continue + # Rolled unconditionally, including for guaranteed drops. Skipping the + # roll at chance 1.0 would make the RNG stream depend on the loot + # table's contents, so editing a number in content.gd would silently + # change every later roll in the world. + if rng.randf() > entry.chance: + continue + if not entry.instanced: + _place_loot(entry.item, at, Vector2.ZERO, 0) + continue + # One copy per player who was alive for the kill. Laid out on a ring so + # that a debug view of every copy at once is legible; in play each + # player is only ever sent their own, so they all appear in the middle. + var owners := _living_peers() + for i in owners.size(): + var angle := TAU * float(i) / float(owners.size()) + _place_loot(entry.item, at, + Vector2.RIGHT.rotated(angle) * SimConfig.LOOT_INSTANCED_SPREAD, + owners[i]) + + +## Spawn at [param at] + [param offset], falling back to [param at] when the +## offset would put the item inside geometry -- unreachable loot is worse than +## two items in the same place. +func _place_loot(item: StringName, at: Vector2, offset: Vector2, owner_peer: int) -> void: + var want := at + offset + if offset != Vector2.ZERO and map.circle_blocked(want, LOOT_CLEARANCE): + want = at + spawn_loot(item, want, owner_peer) + + +func _living_peers() -> Array[int]: + var out: Array[int] = [] + for p in players.values(): + if p.alive: + out.append(p.peer_id) + out.sort() # stable ordering, so the ring layout is not dictionary order + return out + + +## Take the nearest item this player is allowed to have. Returns whether one +## was actually picked up. +func _try_pickup(p: SimPlayer) -> bool: + var best: SimLoot = null + var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS + for l in loot.values(): + if not l.visible_to(p.peer_id): + continue + var d := p.pos.distance_squared_to(l.pos) + if d <= best_d: + best_d = d + best = l + if best == null: + return false + if p.add_item(best.item) < 0: + return false # bags full; the item stays exactly where it was + loot.erase(best.id) + events.append({"t": SimEvent.Type.ITEM_PICKED_UP, "peer": p.peer_id, "item": best.item}) + return true + + +func _use_slot(p: SimPlayer, slot_index: int) -> void: + if slot_index < 0 or slot_index >= p.inventory.size(): + return + var item := p.inventory[slot_index] + var def := Items.get_def(item) + if def == null: + return + match def.effect: + ItemDef.Effect.HEAL: + # Refused rather than wasted. Spending a potion at full health is + # not a decision anyone makes on purpose, so it must not be one a + # mistimed keypress can make for them. + if p.heal_percent(def.effect_value) <= 0: + return + _: + pass + p.take_slot(slot_index) + events.append({"t": SimEvent.Type.ITEM_USED, "peer": p.peer_id, "item": item}) + + +func _drop_slot(p: SimPlayer, slot_index: int) -> void: + if slot_index < 0 or slot_index >= p.inventory.size(): + return + if p.inventory[slot_index] == Items.NONE: + return + var item := p.take_slot(slot_index) + # Anything dropped becomes world-shared, even if it arrived as an instanced + # drop. That is what makes dropping worth having: an item you do not want + # should be able to reach someone who does. + _place_loot(item, p.pos, Vector2.ZERO, 0) + events.append({"t": SimEvent.Type.ITEM_DROPPED, "peer": p.peer_id, "item": item}) + + func _step_escape(p: SimPlayer, frame: InputFrame) -> void: # A dropped connection is treated as holding the button down. Pulling the # plug then costs exactly what pressing escape costs -- one second of @@ -437,6 +613,7 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void: 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) # The def id rides along so the instance layer can score it without # looking up an actor that is about to stop existing. events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)}) @@ -450,6 +627,7 @@ func _damage_boss(amount: int) -> void: 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)}) diff --git a/src/ui/hud.gd b/src/ui/hud.gd index c347280..a0ac5ff 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -5,6 +5,11 @@ extends CanvasLayer const MARGIN := 24.0 const BAR_W := 260.0 const BAR_H := 16.0 +## Inventory slot box, and the gap between boxes. +const SLOT := 46.0 +const SLOT_GAP := 8.0 +## How far above the bottom of the screen the inventory row sits. +const SLOT_BOTTOM := 26.0 signal respawn_pressed @@ -136,6 +141,8 @@ func _draw_hud() -> void: _draw_cleared_countdown() _draw_roster() + _draw_inventory() + _draw_pickup_prompt() if not client.my_alive: # Centred on the canvas, which is only correct because _canvas actually @@ -150,6 +157,64 @@ func _draw_hud() -> void: Color(1.0, 0.2, 0.25, 0.18 * _hit_flash)) +## 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 +## snapshot, so it is what the server says you have, never a local guess. +func _draw_inventory() -> void: + if client.my_inventory.is_empty(): + return + var count := client.my_inventory.size() + var total := float(count) * SLOT + float(count - 1) * SLOT_GAP + var origin := Vector2((_canvas.size.x - total) * 0.5, + _canvas.size.y - SLOT_BOTTOM - SLOT) + var held := client.held_slot() + for i in count: + var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0) + var item := Items.by_index(int(client.my_inventory[i])) + var def := Items.get_def(item) + var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \ + else Color(0.3, 0.33, 0.42, 0.6) + _canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72)) + _canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5) + # The slot number, because the key that uses it is the only thing the + # player actually needs to know about a slot. + _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(4.0, 13.0), + str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, + Color(0.5, 0.55, 0.68)) + if def == null: + continue + var icon := Art.item_icon(item) + var size := icon.size * Art.SCALE + _canvas.draw_texture_rect_region(Art.TILESET, + Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon) + _canvas.draw_string(ThemeDB.fallback_font, + Vector2(origin.x, origin.y + SLOT + 15.0), + "1-%d use shift+1-%d drop E pick up" % [count, count], + HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66)) + + +## What pressing interact right now would do. The server runs the same search +## for itself and does not care what this concluded -- this is a label, not a +## decision. +func _draw_pickup_prompt() -> void: + var near := client.loot_in_reach() + if near.is_empty() or not client.my_alive: + return + var item := Items.by_index(int(near["item"])) + var def := Items.get_def(item) + if def == null: + return + var full := client.inventory_full() + var text := "inventory full -- %s stays where it is" % def.display_name \ + if full else "E take %s" % def.display_name + var tint := Color(0.85, 0.5, 0.45) if full else def.tint + _canvas.draw_string(ThemeDB.fallback_font, + Vector2(_canvas.size.x * 0.5 - 150.0, + _canvas.size.y - SLOT_BOTTOM - SLOT - 22.0), + text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint) + + ## A thin bar under health: progress toward the next level, and the level ## itself. Drawn from the server's numbers, never recomputed locally. func _draw_xp_bar(at: Vector2) -> void: diff --git a/src/view/art.gd b/src/view/art.gd index 27ada7d..fc8872c 100644 --- a/src/view/art.gd +++ b/src/view/art.gd @@ -99,6 +99,24 @@ const ENEMY_IDLE: Array[Rect2] = [ ] const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon +## 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 +## sprite in this set, so the ration is a gold flask standing in for one until +## the art pass revisits it. +const ITEM_ICONS: Array[Rect2] = [ + Rect2(288, 352, 16, 16), # small red flask -> health potion + Rect2(336, 352, 16, 16), # small gold flask -> Warden's ration +] + + +## Icon for an item id, falling back to the first entry so an item added +## without art draws as something rather than as nothing. +static func item_icon(item: StringName) -> Rect2: + var def := Items.get_def(item) + if def == null: + return ITEM_ICONS[0] + return ITEM_ICONS[clampi(def.visual, 0, ITEM_ICONS.size() - 1)] + # --- Bullets ---------------------------------------------------------------- # assets/local/bullets.png is composed by tools/build_local_assets.py: 8 frames # across, one row per SimConfig.KIND_*. The raw pack could not be used directly diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index b291697..8895c0d 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -58,6 +58,13 @@ func _process(_delta: float) -> void: _bound.shot_fired.connect(func() -> void: sfx.play(Art.SFX_SHOOT, -14.0)) _bound.enemy_died.connect(func() -> void: sfx.play(Art.SFX_ENEMY_DEATH, -8.0)) _bound.boss_died.connect(func() -> void: sfx.play(Art.SFX_BOSS_DEATH, -2.0)) + # Item feedback rides the same rule as every other sound here: it + # plays because a server event arrived, never because the client + # guessed a pickup succeeded. + _bound.item_picked_up.connect(func(_i: StringName) -> void: + sfx.play(Art.SFX_ENEMY_DEATH, -16.0)) + _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)) _refresh_characters() diff --git a/src/view/world_view.gd b/src/view/world_view.gd index fb0acb6..e247f47 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -56,6 +56,9 @@ func _draw() -> void: _draw_terrain() if client.instance_kind == Protocol.InstanceKind.LOBBY: _draw_portal() + for l in client.ground_loot(): + if _visible(l["pos"]): + _draw_loot(l) for e in client.enemies(): if _visible(e["pos"]): _draw_enemy(e) @@ -80,6 +83,9 @@ func _draw_debug() -> void: var b := client.boss_state() if not b.is_empty() and client.boss_def != null: DebugDraw.draw_boss(self, b["pos"], client.boss_def.radius, Rect2()) + for l in client.ground_loot(): + draw_arc(l["pos"], SimConfig.LOOT_PICKUP_RADIUS, 0.0, TAU, 24, + Color(1.0, 0.9, 0.4, 0.5), 1.0) for p in client.remote_players(): DebugDraw.draw_player(self, p["pos"], p["aim"]) DebugDraw.draw_player(self, client.predicted_pos, client.aim) @@ -172,6 +178,24 @@ func _draw_portal() -> void: Color(COL_PORTAL, 0.25 + 0.25 * pulse)) +## An item on the floor. The server has already decided this player may see it +## -- player-instanced loot belonging to someone else never reaches the client +## at all -- so there is nothing to filter here beyond the fog. +func _draw_loot(l: Dictionary) -> void: + var item := Items.by_index(int(l["item"])) + var def := Items.get_def(item) + if def == null: + return + var pos: Vector2 = l["pos"] + # A slow bob and a glow ring. Loot has to read as "pick me up" from across a + # room full of bullets, and a static 16px sprite on a busy floor does not. + var t := float(Time.get_ticks_msec()) * 0.004 + float(int(l["id"])) * 0.7 + var pulse := 0.5 + 0.5 * sin(t) + draw_circle(pos, 11.0 + 2.0 * pulse, Color(def.tint, 0.13 + 0.10 * pulse)) + _draw_sprite(Art.TILESET, Art.item_icon(item), + pos + Vector2(0.0, -3.0 * pulse)) + + func _draw_enemy(e: Dictionary) -> void: var visual := clampi(int(e["visual"]), 0, Art.ENEMY_IDLE.size() - 1) var first: Rect2 = Art.ENEMY_IDLE[visual] diff --git a/tests/unit/test_character_store.gd b/tests/unit/test_character_store.gd index 842057f..6d09a8b 100644 --- a/tests/unit/test_character_store.gd +++ b/tests/unit/test_character_store.gd @@ -171,3 +171,42 @@ func test_suggested_names_vary() -> void: for _i in 40: seen[Character.random_name(rng)] = true assert_gt(seen.size(), 10, "a fixed suggestion would be worse than none") + + +## Inventories live on the character, which is what makes them survive a +## restart, a character swap and a walk into a dungeon. +func test_the_inventory_survives_a_save_and_reload() -> void: + var c := store.create_character(ACC, "Packrat") + var carried: Array[StringName] = [Items.HEALTH_POTION, Items.NONE, Items.WARDENS_RATION] + store.set_inventory(ACC, c.id, carried) + + var reloaded := CharacterStore.new(store._path) + assert_true(reloaded.load_from_disk()) + var got := reloaded.get_character(ACC, c.id) + assert_eq(got.inventory.size(), SimConfig.INVENTORY_SLOTS) + assert_eq(got.inventory[0], Items.HEALTH_POTION) + assert_eq(got.inventory[1], Items.NONE) + assert_eq(got.inventory[2], Items.WARDENS_RATION) + + +## A save file written by a build that had an item this one does not must still +## load -- as an empty slot, never as a different item. +func test_an_unknown_item_in_a_save_file_becomes_an_empty_slot() -> void: + var restored := Character.from_dict({ + "id": "x", "name": "Old", "xp": 0, + "inventory": ["health_potion", "phlogiston"], + }) + assert_eq(restored.inventory[0], Items.HEALTH_POTION) + assert_eq(restored.inventory[1], Items.NONE) + + +func test_a_character_from_before_inventories_existed_still_loads() -> void: + var restored := Character.from_dict({"id": "y", "name": "Legacy", "xp": 500}) + assert_eq(restored.inventory.size(), SimConfig.INVENTORY_SLOTS) + assert_eq(restored.display_name, "Legacy") + + +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) diff --git a/tests/unit/test_input_frame.gd b/tests/unit/test_input_frame.gd index 560a37e..b49a858 100644 --- a/tests/unit/test_input_frame.gd +++ b/tests/unit/test_input_frame.gd @@ -41,3 +41,32 @@ func test_frame_is_exactly_the_declared_size() -> void: var b := StreamPeerBuffer.new() InputFrame.make(1, Vector2.ONE, 1.0, 7).write(b) assert_eq(b.data_array.size(), InputFrame.SIZE) + + +## The slot is what makes "use item" expressible without a second message type. +func test_round_trip_preserves_the_inventory_slot() -> void: + var out := _round_trip(InputFrame.make(1, Vector2.ZERO, 0.0, + InputFrame.BTN_USE, SimConfig.INVENTORY_SLOTS - 1)) + assert_eq(out.slot, SimConfig.INVENTORY_SLOTS - 1) + assert_true(out.pressed(InputFrame.BTN_USE)) + assert_false(out.pressed(InputFrame.BTN_DROP)) + + +## Every slot the game has must survive the byte it is sent in. This is the +## check that fails if the slot count ever outgrows the format. +func test_every_slot_index_survives_the_wire() -> void: + for i in SimConfig.INVENTORY_SLOTS: + assert_eq(_round_trip(InputFrame.make(1, Vector2.ZERO, 0.0, 0, i)).slot, i) + assert_lte(SimConfig.INVENTORY_SLOTS, 256, + "the slot rides in one byte") + + +## Button bits have to stay distinct, or one action would trigger another. +func test_button_bits_do_not_overlap() -> void: + var bits := [InputFrame.BTN_FIRE, InputFrame.BTN_ESCAPE, InputFrame.BTN_INTERACT, + InputFrame.BTN_USE, InputFrame.BTN_DROP] + var seen := 0 + for bit in bits: + assert_eq(seen & bit, 0, "bit %d collides with an earlier one" % bit) + assert_lte(bit, 128, "the button field is a single byte") + seen |= bit diff --git a/tests/unit/test_items.gd b/tests/unit/test_items.gd new file mode 100644 index 0000000..53c06a8 --- /dev/null +++ b/tests/unit/test_items.gd @@ -0,0 +1,120 @@ +extends GutTest +## The item registry. Small, but two of these are load-bearing: the wire index +## and the art table both key off Items.ORDER, and a mismatch in either is +## silent rather than loud. + + +func test_every_registered_id_has_a_definition() -> void: + for id in Items.ORDER: + assert_not_null(Items.get_def(id), "%s is in ORDER with no definition" % id) + + +func test_the_wire_index_round_trips() -> void: + for id in Items.ORDER: + assert_eq(Items.by_index(Items.index_of(id)), id) + + +## Index 0 is reserved for "empty slot". If an item ever landed on it, every +## empty slot in the game would quietly contain that item. +func test_index_zero_is_reserved_for_nothing() -> void: + assert_eq(Items.by_index(0), Items.NONE) + assert_eq(Items.index_of(Items.NONE), 0) + for id in Items.ORDER: + assert_gt(Items.index_of(id), 0, "%s must not occupy the empty index" % id) + + +## An id or index this build does not know reads as empty, never as whatever +## happens to sit nearby. A client one version behind must see an unknown item +## as an empty slot, not as a potion. +func test_unknown_items_decay_to_nothing() -> void: + assert_eq(Items.index_of(&"not_a_real_item"), 0) + assert_eq(Items.by_index(200), Items.NONE) + assert_eq(Items.by_index(-1), Items.NONE) + assert_null(Items.get_def(&"not_a_real_item")) + + +func test_the_index_fits_in_the_byte_the_wire_gives_it() -> void: + assert_lte(Items.ORDER.size(), 254, + "item ids are sent as a single byte; past this the format has to change") + + +func test_there_is_an_icon_for_every_item() -> void: + for id in Items.ORDER: + var def := Items.get_def(id) + assert_lt(def.visual, Art.ITEM_ICONS.size(), + "%s has visual %d with no icon" % [id, def.visual]) + var icon := Art.item_icon(id) + assert_lte(icon.end.x, float(Art.TILESET.get_width())) + assert_lte(icon.end.y, float(Art.TILESET.get_height())) + + +## Healing is a percentage of maximum health so a potion is worth the same slot +## at level 15 as at level 1. A flat value would be a full heal early and noise +## late, which is the wrong shape for the only consumable in the game. +func test_the_health_potion_heals_a_share_of_maximum_health() -> void: + var def := Items.get_def(Items.HEALTH_POTION) + assert_eq(def.effect, ItemDef.Effect.HEAL) + assert_gt(def.effect_value, 0.0) + assert_lte(def.effect_value, 100.0) + + var p := SimPlayer.new() + p.max_hp = 200 + p.hp = 10 + assert_eq(p.heal_percent(def.effect_value), int(200.0 * def.effect_value / 100.0)) + + +func test_healing_never_overshoots_maximum_health() -> void: + var p := SimPlayer.new() + p.max_hp = 100 + p.hp = 95 + assert_eq(p.heal_percent(80.0), 5) + assert_eq(p.hp, 100) + assert_eq(p.heal_percent(80.0), 0, "already full heals for nothing") + + +## The ration's entire job is to exercise the player-instanced loot path. If it +## ever gains an effect, that job needs a new holder. +func test_the_ration_does_nothing() -> void: + assert_eq(Items.get_def(Items.WARDENS_RATION).effect, ItemDef.Effect.NONE) + + +func test_a_player_starts_with_the_configured_number_of_empty_slots() -> void: + var p := SimPlayer.new() + assert_eq(p.inventory.size(), SimConfig.INVENTORY_SLOTS) + assert_eq(p.free_slot(), 0) + for item in p.inventory: + assert_eq(item, Items.NONE) + + +func test_slot_operations_refuse_indices_off_the_end() -> void: + # The index arrives off the wire, so nonsense has to be safe rather than + # merely unlikely. + var p := SimPlayer.new() + p.add_item(Items.HEALTH_POTION) + assert_eq(p.take_slot(-1), Items.NONE) + assert_eq(p.take_slot(9999), Items.NONE) + assert_eq(p.inventory[0], Items.HEALTH_POTION, "and must not disturb the bag") + + +func test_a_full_bag_refuses_more() -> void: + var p := SimPlayer.new() + for i in SimConfig.INVENTORY_SLOTS: + assert_eq(p.add_item(Items.HEALTH_POTION), i) + assert_eq(p.free_slot(), -1) + assert_eq(p.add_item(Items.HEALTH_POTION), -1) + + +## A save written when the game had a different slot count must still load. +func test_setting_an_inventory_pads_and_trims_to_the_slot_count() -> void: + var p := SimPlayer.new() + var short: Array[StringName] = [Items.HEALTH_POTION] + p.set_inventory(short) + assert_eq(p.inventory.size(), SimConfig.INVENTORY_SLOTS) + assert_eq(p.inventory[0], Items.HEALTH_POTION) + assert_eq(p.inventory[SimConfig.INVENTORY_SLOTS - 1], Items.NONE) + + var long: Array[StringName] = [] + for _i in SimConfig.INVENTORY_SLOTS + 5: + long.append(Items.WARDENS_RATION) + p.set_inventory(long) + assert_eq(p.inventory.size(), SimConfig.INVENTORY_SLOTS) diff --git a/tests/unit/test_items.gd.uid b/tests/unit/test_items.gd.uid new file mode 100644 index 0000000..44c7395 --- /dev/null +++ b/tests/unit/test_items.gd.uid @@ -0,0 +1 @@ +uid://ceonypgg5vaw diff --git a/tests/unit/test_loot.gd b/tests/unit/test_loot.gd new file mode 100644 index 0000000..f62cf5c --- /dev/null +++ b/tests/unit/test_loot.gd @@ -0,0 +1,357 @@ +extends GutTest +## Ground loot and the item actions that move things on and off it. +## +## Everything here goes through [method SimWorld.queue_input] rather than +## calling the private handlers, because the whole point of putting item actions +## in the input frame is that they are subject to the same rules as movement. +## A test that reached past that would prove nothing about what a client can +## actually do. + +const ME := 1 +const THEM := 2 + +var world: SimWorld + + +func before_each() -> void: + world = SimWorld.new(7) + var me := world.add_player(ME, "me") + me.pos = Vector2.ZERO + me.spawn_grace = 0 + + +## One tick of held input. Ticks have to advance or queue_input discards the +## frame as a duplicate, which is exactly what it should do. +func _hold(peer: int, ticks: int, buttons: int, slot: int = 0) -> void: + for _i in ticks: + var frames: Array[InputFrame] = [ + InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, buttons, slot)] + world.queue_input(peer, frames) + world.step() + + +## Press and release, so the next press is a fresh edge. +func _tap(peer: int, buttons: int, slot: int = 0) -> void: + _hold(peer, 1, buttons, slot) + _hold(peer, 1, 0, slot) + + +func _events_of(kind: int) -> Array: + var out := [] + for ev in world.events: + if int(ev["t"]) == kind: + out.append(ev) + return out + + +# --- Picking up ------------------------------------------------------------- + +func test_pressing_interact_next_to_an_item_takes_it() -> void: + world.spawn_loot(Items.HEALTH_POTION, Vector2(20.0, 0.0)) + _tap(ME, InputFrame.BTN_INTERACT) + assert_eq(world.players[ME].inventory[0], Items.HEALTH_POTION) + assert_eq(world.loot.size(), 0, "and it leaves the floor") + assert_eq(_events_of(SimEvent.Type.ITEM_PICKED_UP).size(), 1) + + +func test_an_item_out_of_reach_is_not_taken() -> void: + world.spawn_loot(Items.HEALTH_POTION, + Vector2(SimConfig.LOOT_PICKUP_RADIUS + 20.0, 0.0)) + _tap(ME, InputFrame.BTN_INTERACT) + assert_eq(world.players[ME].free_slot(), 0, "nothing should have been picked up") + assert_eq(world.loot.size(), 1) + + +## Interact is held down while walking around, and the client repeats the last +## few frames every tick. Level-triggered, standing on a pile would hoover it up +## in four ticks. +func test_holding_interact_takes_exactly_one_item() -> void: + for i in 3: + world.spawn_loot(Items.HEALTH_POTION, Vector2(float(i) * 4.0, 0.0)) + _hold(ME, 30, InputFrame.BTN_INTERACT) + assert_eq(world.loot.size(), 2, "two items should still be on the floor") + + +func test_releasing_and_pressing_again_takes_the_next_one() -> void: + world.spawn_loot(Items.HEALTH_POTION, Vector2(4.0, 0.0)) + world.spawn_loot(Items.HEALTH_POTION, Vector2(8.0, 0.0)) + _tap(ME, InputFrame.BTN_INTERACT) + _tap(ME, InputFrame.BTN_INTERACT) + assert_eq(world.loot.size(), 0) + + +## Nothing is destroyed by a full bag. Silently deleting the item would be the +## kind of loss a player cannot be compensated for. +func test_a_full_inventory_leaves_the_item_on_the_floor() -> void: + var me := world.players[ME] + for _i in SimConfig.INVENTORY_SLOTS: + me.add_item(Items.WARDENS_RATION) + world.spawn_loot(Items.HEALTH_POTION, Vector2(10.0, 0.0)) + _tap(ME, InputFrame.BTN_INTERACT) + assert_eq(world.loot.size(), 1, "the item must survive a failed pickup") + assert_eq(_events_of(SimEvent.Type.ITEM_PICKED_UP).size(), 0) + + +## The portal and pickup share the interact key. A full bag must not leave a +## player standing on the portal unable to use it. +func test_a_failed_pickup_does_not_block_the_portal() -> void: + world.portal_enabled = true + world.portal_pos = Vector2.ZERO + var me := world.players[ME] + for _i in SimConfig.INVENTORY_SLOTS: + me.add_item(Items.WARDENS_RATION) + world.spawn_loot(Items.HEALTH_POTION, Vector2(10.0, 0.0)) + _hold(ME, 1, InputFrame.BTN_INTERACT) + assert_gt(_events_of(SimEvent.Type.PORTAL_USED).size(), 0, + "the portal should still answer when the pickup could not happen") + + +# --- Using ------------------------------------------------------------------ + +func test_using_a_potion_heals_and_consumes_it() -> void: + var me := world.players[ME] + me.add_item(Items.HEALTH_POTION) + me.hp = 10 + _tap(ME, InputFrame.BTN_USE, 0) + assert_gt(me.hp, 10, "the potion should have healed") + assert_eq(me.inventory[0], Items.NONE, "and been consumed") + assert_eq(_events_of(SimEvent.Type.ITEM_USED).size(), 1) + + +## A mistimed keypress must not cost a potion. Refusing the use is the only way +## to make that true, since nothing else in the game asks for confirmation. +func test_a_potion_at_full_health_is_refused_rather_than_wasted() -> void: + var me := world.players[ME] + me.add_item(Items.HEALTH_POTION) + me.hp = me.max_hp + _tap(ME, InputFrame.BTN_USE, 0) + assert_eq(me.inventory[0], Items.HEALTH_POTION, "it should still be there") + + +## Refilling the slot mid-hold is what makes this test bite. Checking only that +## one potion was spent proves nothing: a level-triggered bug empties the slot +## on the first tick and then finds nothing left to spend either way. +func test_holding_the_use_key_spends_exactly_one_potion() -> void: + var me := world.players[ME] + me.add_item(Items.HEALTH_POTION) + me.hp = 1 + _hold(ME, 5, InputFrame.BTN_USE, 0) + assert_eq(me.inventory[0], Items.NONE, "the first press should drink it") + + # Same key still down, same slot, a fresh potion in it. + me.inventory[0] = Items.HEALTH_POTION + me.hp = 1 + _hold(ME, 20, InputFrame.BTN_USE, 0) + assert_eq(me.inventory[0], Items.HEALTH_POTION, + "a held key is one action, however many frames carry it") + + +## Tapping a second slot while the first is still held is a real second action, +## so the slot has to be part of the edge and not just the button bit. +func test_a_different_slot_while_held_is_a_second_action() -> void: + var me := world.players[ME] + me.add_item(Items.WARDENS_RATION) + me.add_item(Items.WARDENS_RATION) + _hold(ME, 3, InputFrame.BTN_USE, 0) + _hold(ME, 3, InputFrame.BTN_USE, 1) + assert_eq(me.inventory[0], Items.NONE) + assert_eq(me.inventory[1], Items.NONE) + + +## The ration has no effect, and is still spent. "Does nothing" has to mean a +## completed transaction, or it proves nothing about the path it exists to test. +func test_a_useless_item_is_still_consumed() -> void: + var me := world.players[ME] + me.add_item(Items.WARDENS_RATION) + me.hp = me.max_hp + _tap(ME, InputFrame.BTN_USE, 0) + assert_eq(me.inventory[0], Items.NONE) + + +func test_using_an_empty_slot_does_nothing() -> void: + _tap(ME, InputFrame.BTN_USE, 2) + assert_eq(_events_of(SimEvent.Type.ITEM_USED).size(), 0) + + +## The slot index comes off the wire, so a hostile value has to be inert rather +## than merely unusual. +func test_a_slot_index_off_the_end_is_harmless() -> void: + var me := world.players[ME] + me.add_item(Items.HEALTH_POTION) + _tap(ME, InputFrame.BTN_USE, 200) + _tap(ME, InputFrame.BTN_DROP, 250) + assert_eq(me.inventory[0], Items.HEALTH_POTION) + assert_eq(world.loot.size(), 0) + + +# --- Dropping --------------------------------------------------------------- + +func test_dropping_puts_the_item_back_on_the_floor() -> void: + var me := world.players[ME] + me.add_item(Items.HEALTH_POTION) + _tap(ME, InputFrame.BTN_DROP, 0) + assert_eq(me.inventory[0], Items.NONE) + assert_eq(world.loot.size(), 1) + assert_eq(_events_of(SimEvent.Type.ITEM_DROPPED).size(), 1) + + +## Same refill trick as the use test, for the same reason. +func test_holding_the_drop_key_drops_exactly_one_item() -> void: + var me := world.players[ME] + me.add_item(Items.HEALTH_POTION) + _hold(ME, 5, InputFrame.BTN_DROP, 0) + me.inventory[0] = Items.WARDENS_RATION + _hold(ME, 20, InputFrame.BTN_DROP, 0) + assert_eq(me.inventory[0], Items.WARDENS_RATION, + "holding drop must not empty the bag one slot per tick") + assert_eq(world.loot.size(), 1) + + +func test_what_you_drop_can_be_taken_by_someone_else() -> void: + var them := world.add_player(THEM, "them") + them.pos = Vector2(10.0, 0.0) + them.spawn_grace = 0 + world.players[ME].add_item(Items.HEALTH_POTION) + _tap(ME, InputFrame.BTN_DROP, 0) + _tap(THEM, InputFrame.BTN_INTERACT) + assert_eq(them.inventory[0], Items.HEALTH_POTION, + "a dropped item is world-shared, whatever it was before") + + +## An instanced item becomes shared the moment it is dropped. That is the point +## of being able to drop things: a trophy you do not want should be able to +## reach someone who does. +func test_dropping_an_instanced_item_makes_it_shared() -> void: + world.players[ME].add_item(Items.WARDENS_RATION) + _tap(ME, InputFrame.BTN_DROP, 0) + for l in world.loot.values(): + assert_eq(l.owner_peer, 0) + + +func test_dropping_an_empty_slot_does_nothing() -> void: + _tap(ME, InputFrame.BTN_DROP, 1) + assert_eq(world.loot.size(), 0) + + +# --- Visibility ------------------------------------------------------------- + +func test_instanced_loot_cannot_be_taken_by_anyone_else() -> void: + world.spawn_loot(Items.WARDENS_RATION, Vector2(8.0, 0.0), THEM) + _tap(ME, InputFrame.BTN_INTERACT) + assert_eq(world.loot.size(), 1, "it is not mine to take") + assert_eq(world.players[ME].free_slot(), 0) + + +func test_instanced_loot_can_be_taken_by_its_owner() -> void: + world.spawn_loot(Items.WARDENS_RATION, Vector2(8.0, 0.0), ME) + _tap(ME, InputFrame.BTN_INTERACT) + assert_eq(world.players[ME].inventory[0], Items.WARDENS_RATION) + + +## Nobody else can see it, so leaving it behind would be an entity the instance +## carries around invisibly until it closes. +func test_leaving_takes_your_instanced_loot_with_you() -> void: + world.spawn_loot(Items.WARDENS_RATION, Vector2(8.0, 0.0), ME) + world.spawn_loot(Items.HEALTH_POTION, Vector2(8.0, 0.0)) + world.remove_player(ME) + assert_eq(world.loot.size(), 1, "the shared item stays") + for l in world.loot.values(): + assert_eq(l.owner_peer, 0) + + +# --- Drops ------------------------------------------------------------------ + +func test_a_boss_kill_drops_a_shared_potion_and_a_ration_each() -> void: + var them := world.add_player(THEM, "them") + them.pos = Vector2(60.0, 0.0) + var boss := world.spawn_boss(Content.warden()) + world._damage_boss(boss.def.max_hp * 2) + var shared := 0 + var mine := 0 + var theirs := 0 + for l in world.loot.values(): + if l.owner_peer == 0: + shared += 1 + elif l.owner_peer == ME: + mine += 1 + elif l.owner_peer == THEM: + theirs += 1 + assert_eq(shared, 1, "one potion for the party to divide") + assert_eq(mine, 1, "and a ration each") + assert_eq(theirs, 1) + + +func test_a_dead_player_earns_no_instanced_drop() -> void: + var them := world.add_player(THEM, "them") + them.alive = false + var boss := world.spawn_boss(Content.warden()) + world._damage_boss(boss.def.max_hp * 2) + for l in world.loot.values(): + assert_ne(l.owner_peer, THEM, "you have to be alive for the kill") + + +## Rare means rare: a run should be survivable on what it hands you, never +## comfortably. Checked statistically rather than exactly, because the roll uses +## the world's RNG and pinning the exact count would pin the RNG. +func test_a_trash_enemy_drops_a_potion_only_sometimes() -> void: + var kills := 400 + var dropped := 0 + for i in kills: + var solo := SimWorld.new(i * 31 + 5) + var e := solo.spawn_enemy(Content.drifter(), Vector2.ZERO) + solo._damage_enemy(e, e.def.max_hp * 2) + dropped += solo.loot.size() + assert_gt(dropped, 0, "trash has to drop something eventually") + assert_lt(dropped, kills / 2, + "if most kills drop a potion, potions are not worth a slot") + + +func test_the_practice_dummy_drops_nothing() -> void: + var e := world.spawn_enemy(Content.dummy(), Vector2(100.0, 0.0)) + world._damage_enemy(e, e.def.max_hp * 2) + assert_eq(world.loot.size(), 0, "the hub target is not a loot piñata") + + +# --- Housekeeping ----------------------------------------------------------- + +## Only the hub can realistically reach the cap -- dungeons close and take +## their litter with them -- but unbounded growth in the one world that never +## closes is worth a ceiling. +func test_ground_loot_is_capped() -> void: + for i in SimConfig.MAX_LOOT_PER_INSTANCE + 20: + world.spawn_loot(Items.HEALTH_POTION, Vector2(float(i), 200.0)) + assert_lte(world.loot.size(), SimConfig.MAX_LOOT_PER_INSTANCE) + + +func test_loot_ids_never_collide_with_actor_ids() -> void: + var e := world.spawn_enemy(Content.turret(), Vector2(100.0, 100.0)) + var l := world.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO) + assert_ne(l.id, e.id, "loot shares the actor id space, so it must share the counter") + + +func test_an_unknown_item_cannot_be_put_on_the_floor() -> void: + assert_null(world.spawn_loot(&"phlogiston", Vector2.ZERO)) + + +## The inventory belongs to the character, not to the room. Walking into a +## dungeon with the potions you were carrying is the entire point of carrying +## them. +func test_inventory_survives_an_instance_transition() -> void: + var me := world.players[ME] + me.add_item(Items.HEALTH_POTION) + me.reset_for_instance(Vector2(50.0, 50.0), SimConfig.SPAWN_GRACE_TICKS) + assert_eq(me.inventory[0], Items.HEALTH_POTION) + + +## A replica never decides anything, loot included. This is the same guarantee +## the rest of the simulation makes, checked for the newest actor type. +func test_a_replica_never_creates_loot_of_its_own() -> void: + var replica := SimWorld.new(7) + replica.authoritative = false + replica.add_player(ME, "me") + var e := replica.spawn_enemy(Content.drifter(), Vector2(10.0, 0.0)) + e.hp = 1 + for _i in 300: + replica.step() + assert_eq(replica.loot.size(), 0, + "a client must never invent an item for itself to pick up") diff --git a/tests/unit/test_loot.gd.uid b/tests/unit/test_loot.gd.uid new file mode 100644 index 0000000..3295ec6 --- /dev/null +++ b/tests/unit/test_loot.gd.uid @@ -0,0 +1 @@ +uid://bj5m8a8m1lxdj diff --git a/tests/unit/test_net_codec.gd b/tests/unit/test_net_codec.gd index 289944f..e228fe1 100644 --- a/tests/unit/test_net_codec.gd +++ b/tests/unit/test_net_codec.gd @@ -271,3 +271,134 @@ func test_a_truncated_roster_packet_does_not_read_past_the_end() -> void: assert_lte(out.size(), 2, "a roster cut at %d bytes must degrade, not invent entries" % cut) assert_eq(NetCodec.decode_roster(full).size(), 2, "and the full packet still works") + + +# --- Inventory and loot ----------------------------------------------------- + +func test_the_snapshot_carries_the_observers_own_inventory() -> void: + var p: SimPlayer = world.players[42] + p.add_item(Items.HEALTH_POTION) + p.inventory[2] = Items.WARDENS_RATION + var snap := NetCodec.decode_snapshot( + NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42)) + var inv: Array = snap["inventory"] + assert_eq(inv.size(), SimConfig.INVENTORY_SLOTS) + assert_eq(Items.by_index(int(inv[0])), Items.HEALTH_POTION) + assert_eq(Items.by_index(int(inv[1])), Items.NONE) + assert_eq(Items.by_index(int(inv[2])), Items.WARDENS_RATION) + + +## Nobody needs to know what a party member is carrying, and the cheapest way to +## keep that true is to never put it on the wire. +func test_the_snapshot_never_carries_another_players_inventory() -> void: + var them := world.add_player(43, "them") + them.pos = Vector2(120.0, -64.0) + them.add_item(Items.HEALTH_POTION) + var snap := NetCodec.decode_snapshot( + NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42)) + assert_eq((snap["players"] as Array).size(), 2, "they should still be visible") + for inv_index in snap["inventory"]: + assert_eq(int(inv_index), 0, "but their bag must not be in this packet") + + +func test_ground_loot_round_trips() -> void: + var l := world.spawn_loot(Items.HEALTH_POTION, Vector2(64.0, -32.0)) + var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world)) + var got: Dictionary = snap["loot"][0] + assert_eq(int(got["id"]), l.id) + assert_almost_eq((got["pos"] as Vector2).x, 64.0, 0.01) + assert_almost_eq((got["pos"] as Vector2).y, -32.0, 0.01) + assert_eq(Items.by_index(int(got["item"])), Items.HEALTH_POTION) + + +## The wire is where player-instanced loot is actually enforced. A peer is never +## told another player's copy exists, so a modified client has nothing to +## reveal -- this is an interest-management rule, not a UI convention. +func test_another_players_instanced_loot_is_not_on_the_wire() -> void: + var p: SimPlayer = world.players[42] + world.spawn_loot(Items.WARDENS_RATION, p.pos + Vector2(10.0, 0.0), 43) + world.spawn_loot(Items.WARDENS_RATION, p.pos + Vector2(20.0, 0.0), 42) + world.spawn_loot(Items.HEALTH_POTION, p.pos + Vector2(30.0, 0.0)) + var snap := NetCodec.decode_snapshot( + NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42)) + assert_eq((snap["loot"] as Array).size(), 2, + "my ration and the shared potion, never theirs") + + +func test_distant_loot_is_not_sent() -> void: + var p: SimPlayer = world.players[42] + world.spawn_loot(Items.HEALTH_POTION, + p.pos + Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 200.0, 0.0)) + var snap := NetCodec.decode_snapshot( + NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42)) + assert_eq((snap["loot"] as Array).size(), 0) + + +func test_item_events_round_trip() -> void: + var events: Array[Dictionary] = [ + {"t": SimEvent.Type.ITEM_PICKED_UP, "peer": 42, "item": Items.HEALTH_POTION}, + {"t": SimEvent.Type.ITEM_USED, "peer": 42, "item": Items.WARDENS_RATION}, + {"t": SimEvent.Type.ITEM_DROPPED, "peer": 7, "item": Items.HEALTH_POTION}, + ] + var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"] + assert_eq(out.size(), 3) + assert_eq(int(out[0]["t"]), SimEvent.Type.ITEM_PICKED_UP) + assert_eq(out[0]["item"], Items.HEALTH_POTION) + assert_eq(int(out[1]["peer"]), 42) + assert_eq(out[1]["item"], Items.WARDENS_RATION) + assert_eq(int(out[2]["peer"]), 7) + assert_eq(out[2]["item"], Items.HEALTH_POTION) + + +## The events after the item ones must still decode. A fixed-width event whose +## body length is wrong desynchronises the whole rest of the packet, and that +## shows up as unrelated nonsense rather than as a decode error. +func test_events_after_an_item_event_still_decode() -> void: + var events: Array[Dictionary] = [ + {"t": SimEvent.Type.ITEM_USED, "peer": 42, "item": Items.HEALTH_POTION}, + {"t": SimEvent.Type.ENEMY_HIT, "id": 99, "dmg": 12, "hp": 400}, + {"t": SimEvent.Type.PLAYER_DIED, "peer": 42}, + ] + var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"] + assert_eq(out.size(), 3) + assert_eq(int(out[1]["id"]), 99) + assert_eq(int(out[1]["hp"]), 400) + assert_eq(int(out[2]["t"]), SimEvent.Type.PLAYER_DIED) + + +func test_the_character_roster_carries_inventories() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 31 + var c := Character.create("Packrat", rng) + c.inventory[0] = Items.HEALTH_POTION + c.inventory[3] = Items.WARDENS_RATION + var out := NetCodec.decode_characters( + NetCodec.encode_characters([c] as Array[Character], c.id)) + var inv: Array = out["characters"][0]["inventory"] + assert_eq(Items.by_index(int(inv[0])), Items.HEALTH_POTION) + assert_eq(Items.by_index(int(inv[3])), Items.WARDENS_RATION) + + +## The snapshot is parsed positionally from the front, so a packet with no +## players, enemies or boss still has to land on the right byte for the +## inventory and loot that follow. +func test_an_empty_world_snapshot_still_decodes_its_tail() -> void: + var empty := SimWorld.new(1) + empty.spawn_loot(Items.HEALTH_POTION, Vector2(5.0, 5.0)) + var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(empty)) + assert_eq((snap["players"] as Array).size(), 0) + assert_eq((snap["inventory"] as Array).size(), SimConfig.INVENTORY_SLOTS) + assert_eq((snap["loot"] as Array).size(), 1) + + +func test_an_input_frame_carries_its_slot_over_the_wire() -> void: + var frames: Array[InputFrame] = [ + InputFrame.make(10, Vector2.ZERO, 0.0, InputFrame.BTN_USE, 3), + InputFrame.make(11, Vector2.ZERO, 0.0, InputFrame.BTN_DROP, 1), + ] + var out := NetCodec.decode_inputs(NetCodec.encode_inputs(frames)) + assert_eq(out.size(), 2) + assert_eq(out[0].slot, 3) + assert_true(out[0].pressed(InputFrame.BTN_USE)) + assert_eq(out[1].slot, 1) + assert_true(out[1].pressed(InputFrame.BTN_DROP)) diff --git a/tools/diag_loot.gd b/tools/diag_loot.gd new file mode 100644 index 0000000..7273b32 --- /dev/null +++ b/tools/diag_loot.gd @@ -0,0 +1,285 @@ +extends Node +## End-to-end check of inventory and loot: drop -> snapshot -> pick up -> +## persist -> use -> drop again, plus the two loot visibilities. +## +## godot --headless --path . res://tools/diag_loot.tscn +## +## Runs as a scene because ServerRuntime needs the Net autoload. Exits non-zero +## on any failure, so it gates like a test. +## +## It exists for the same reason tools/diag_progression.tscn does: the bot smoke +## test cannot cover this. Bots are poor shots, so they almost never produce a +## drop, and the one thing worth checking end to end here is precisely what +## happens after something dies. +## +## Input is driven through Net.send_input with real encoded frames rather than +## by calling the world's handlers, because the whole claim being tested is that +## a client can do all of this with nothing but an InputFrame. + +const STORE_PATH := "user://diag_loot.json" +## A second player in the world who is deliberately NOT in inst.peers: nothing +## is ever sent to it, so it costs no transport, but it is a real SimPlayer as +## far as instanced drops and per-peer snapshot encoding are concerned. +const GHOST := 999 + +var _fails: Array[String] = [] +var _step: int = 0 +var _srv: ServerRuntime +var _account: int = 515151 +var _character: Character +var _hp_before_use: int = 0 +## Tick of the most recent frame this file sent, so each step can confirm the +## server actually consumed it. +var _last_press_tick: int = 0 +## True once this file is the only thing sending input. +var _driving: bool = false + + +func _ready() -> void: + DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) + GameOpts.bot_client = true + GameOpts.boss_rush = true # a quiet room: the boss, and whatever we place + GameOpts.account_override = _account + if Net.host(27402) != 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 _world() -> SimWorld: + var inst := _srv.instance_of(Net.LOCAL_PEER) + return inst.world if inst != null else null + + +func _me() -> SimPlayer: + var w := _world() + return w.players.get(Net.LOCAL_PEER) if w != null else null + + +## One press and its release, encoded and sent exactly as the client would. +## The release matters: item actions are edge-triggered, and a starved server +## coasts on the last frame it was given, so a press with no release would leave +## the button held forever and the next press would not be an edge at all. +func _press(buttons: int, slot: int = 0) -> void: + var w := _world() + var p := _me() + if w == null or p == null: + return + # Numbered from the last input the server ACCEPTED, not from the world tick. + # The bot was numbering its frames INPUT_TARGET_LEAD ahead of the server, so + # a frame numbered from the world tick sits behind last_input_tick and is + # discarded as a duplicate -- correctly, and silently. That is worth + # labouring over: when it happened, the pickup never occurred, and every + # later check still passed, because "the slot is empty" and "the item is on + # the floor" are both true of a world where nothing happened at all. + var base := maxi(w.tick + 2, p.last_input_tick + 1) + var frames: Array[InputFrame] = [ + InputFrame.make(base, Vector2.ZERO, 0.0, buttons, slot), + InputFrame.make(base + 1, Vector2.ZERO, 0.0, 0, slot), + ] + _last_press_tick = base + 1 + Net.send_input(NetCodec.encode_inputs(frames)) + + +## Assert the last press reached the simulation. Without this the whole file +## degrades into checking that nothing happened, which it would do quietly. +func _press_landed(what: String) -> void: + var p := _me() + _check(p != null and p.last_input_tick >= _last_press_tick, + "the %s press reached the simulation" % what) + + +func _stored_inventory() -> Array[StringName]: + var c := _srv.store.get_character(_account, _character.id) + return c.inventory if c != null else ([] as Array[StringName]) + + +func _physics_process(_delta: float) -> void: + _step += 1 + # The client is no longer numbering its own frames, so keep its counter + # level with the server's. Without this it warns about its input lead every + # few ticks -- expected noise, and expected noise in a diagnostic is how a + # real warning gets missed. + if _driving and _world() != null: + Net.client.input_tick = _world().tick + SimConfig.INPUT_TARGET_LEAD + match _step: + 20: _login() + 40: _srv._send_to_dungeon(Net.LOCAL_PEER) + 60: _enter_dungeon() + 70: _kill_something() + 78: _see_the_drop() + 80: _press(InputFrame.BTN_INTERACT) + 88: _took_it() + 90: _press(InputFrame.BTN_DROP, 0) + 98: _dropped_it() + 100: _press(InputFrame.BTN_INTERACT) + 108: _took_it_again() + 110: _press(InputFrame.BTN_USE, 0) + 118: _drank_it() + 120: _kill_the_boss() + 126: _boss_loot() + 132: _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.inventory.size() == SimConfig.INVENTORY_SLOTS, + "and starts with %d empty slots" % SimConfig.INVENTORY_SLOTS) + + +func _enter_dungeon() -> void: + var inst := _srv.instance_of(Net.LOCAL_PEER) + _check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON, + "moved into a dungeon") + if inst == null: + _finish() + return + # Stop the bot driving its own input; from here every frame is one we sent + # deliberately, so a pickup can only happen because this file asked for it. + Net.client.set_physics_process(false) + _driving = true + var p := _me() + # Drop the bot's backlog. The client keeps roughly INPUT_TARGET_LEAD frames + # in flight, so without this the first press queues up behind them. + p.input_queue.clear() + # Long arrival protection instead of god mode: it is a state the game + # already has, so nothing here is testing a code path players never hit. + p.spawn_grace = 100000 + + +func _kill_something() -> void: + var w := _world() + var p := _me() + var def := Content.drifter() + # Forced to a certain drop. The drop RATE is a unit-test question; what this + # file is for is everything that happens after the roll succeeds. + def.loot = [LootDrop.make(Items.HEALTH_POTION, 1.0)] + var e := w.spawn_enemy(def, p.pos + Vector2(60.0, 0.0)) + w._damage_enemy(e, def.max_hp * 2) + _check(w.loot.size() == 1, "a kill leaves an item on the floor") + for l in w.loot.values(): + _check(l.owner_peer == 0, "and trash loot is shared, not instanced") + # Stand on it, so the pickup below is about the button and not about + # walking there. + p.pos = l.pos + + +func _see_the_drop() -> void: + _check(Net.client.ground_loot().size() == 1, + "the item reaches the client through the snapshot") + var near := Net.client.loot_in_reach() + _check(not near.is_empty(), "and the client can tell it is in reach") + + +func _took_it() -> void: + _press_landed("interact") + var p := _me() + _check(p.inventory[0] == Items.HEALTH_POTION, "interact picks it up") + _check(_world().loot.size() == 0, "and it leaves the floor") + _check(_stored_inventory()[0] == Items.HEALTH_POTION, + "the pickup is written to the character store at once") + _check(Items.by_index(int(Net.client.my_inventory[0])) == Items.HEALTH_POTION, + "and the client's own bag agrees") + + +func _dropped_it() -> void: + _press_landed("drop") + var w := _world() + _check(_me().inventory[0] == Items.NONE, "dropping empties the slot") + _check(w.loot.size() == 1, "and puts it back on the floor") + _check(_stored_inventory()[0] == Items.NONE, "the store follows the drop too") + for l in w.loot.values(): + _check(l.owner_peer == 0, "a dropped item is world-shared") + _me().pos = l.pos + + +func _took_it_again() -> void: + _press_landed("second interact") + var p := _me() + _check(p.inventory[0] == Items.HEALTH_POTION, "what you drop can be picked back up") + # Hurt, so the potion has something to do. Set directly rather than shot: + # what is being tested is the item, not hit resolution. + p.hp = maxi(p.max_hp / 4, 1) + _hp_before_use = p.hp + + +func _drank_it() -> void: + _press_landed("use") + var p := _me() + _check(p.hp > _hp_before_use + 5, + "using the potion heals (%d -> %d)" % [_hp_before_use, p.hp]) + _check(p.inventory[0] == Items.NONE, "and consumes it") + _check(_stored_inventory()[0] == Items.NONE, "the store follows the use") + + +func _kill_the_boss() -> void: + var w := _world() + var p := _me() + # A second player, close enough to the boss to be sent the same snapshot. + var ghost := w.add_player(GHOST, "ghost") + ghost.pos = w.boss.pos + Vector2(40.0, 40.0) + ghost.spawn_grace = 100000 + p.pos = w.boss.pos + Vector2(-40.0, 40.0) + w._damage_boss(w.boss.def.max_hp * 2) + + +func _boss_loot() -> void: + var w := _world() + var shared := 0 + var mine := 0 + var theirs := 0 + for l in w.loot.values(): + if l.owner_peer == 0: + shared += 1 + elif l.owner_peer == Net.LOCAL_PEER: + mine += 1 + elif l.owner_peer == GHOST: + theirs += 1 + _check(shared == 1, "the boss leaves one shared potion for the party") + _check(mine == 1 and theirs == 1, "and one ration per living player") + + # The visibility rule is enforced on the wire, so check it there rather than + # by inspecting the world: what matters is what each peer is actually told. + var for_me := NetCodec.decode_snapshot(NetCodec.encode_snapshot( + w, Protocol.COUNTDOWN_NONE, Net.LOCAL_PEER)) + var for_them := NetCodec.decode_snapshot(NetCodec.encode_snapshot( + w, Protocol.COUNTDOWN_NONE, GHOST)) + _check((for_me["loot"] as Array).size() == 2, + "my snapshot holds the shared potion and my ration, nothing else") + _check((for_them["loot"] as Array).size() == 2, + "and theirs holds the shared potion and their ration") + var my_ids := [] + for l: Dictionary in for_me["loot"]: + my_ids.append(int(l["id"])) + var overlap := 0 + for l: Dictionary in for_them["loot"]: + if my_ids.has(int(l["id"])): + overlap += 1 + _check(overlap == 1, + "exactly one of the two items is the same entity -- the shared one") + + +func _finish() -> void: + print("---") + if _fails.is_empty(): + print("LOOT_OK") + else: + print("LOOT_FAIL (%d)" % _fails.size()) + Net.shutdown() + get_tree().quit(0 if _fails.is_empty() else 1) diff --git a/tools/diag_loot.gd.uid b/tools/diag_loot.gd.uid new file mode 100644 index 0000000..1c51a44 --- /dev/null +++ b/tools/diag_loot.gd.uid @@ -0,0 +1 @@ +uid://dr3v6m7esntm6 diff --git a/tools/diag_loot.tscn b/tools/diag_loot.tscn new file mode 100644 index 0000000..4ccd6bd --- /dev/null +++ b/tools/diag_loot.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/diag_loot.gd" id="1"] + +[node name="DiagLoot" type="Node"] +script = ExtResource("1") diff --git a/tools/setup_input_map.gd b/tools/setup_input_map.gd index 484c216..ce37d82 100644 --- a/tools/setup_input_map.gd +++ b/tools/setup_input_map.gd @@ -19,6 +19,11 @@ func _init() -> void: _action("fire", [_mouse(MOUSE_BUTTON_LEFT), _key(KEY_SPACE)]) _action("emergency_escape", [_key(KEY_F)]) _action("interact", [_key(KEY_E)]) + # One per inventory slot. Number keys use the slot; shift-number drops it, + # which is read in code rather than bound here -- a modifier is not an + # action, and duplicating four bindings to express it would be worse. + for i in SimConfig.INVENTORY_SLOTS: + _action("use_slot_%d" % (i + 1), [_key((KEY_1 + i) as Key)]) _action("system_menu", [_key(KEY_ESCAPE)]) _action("debug_overlay", [_key(KEY_F1)]) var err := ProjectSettings.save() diff --git a/tools/smoke.sh b/tools/smoke.sh index 76d6ca7..82d6b0c 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -115,6 +115,10 @@ check "bot2 authenticated" "$OUT/server.log" "authenticated as account check "a character was created" "$OUT/server.log" "created 'bot1'" check "and persisted to the store" "$OUT/characters.json" "bot1" check "and are played" "$OUT/server.log" "playing 'bot1'" +# The bots press interact and the use key while in a dungeon, so the item half +# of the input frame crosses a real socket on every run. What they almost never +# do is produce a drop to act on -- that end of it is tools/diag_loot.tscn. +check "inventories reach the save file" "$OUT/characters.json" "\"inventory\"" check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance" check "emergency escape completed" "$OUT/server.log" "escaped to lobby" check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"