Four always-on-screen slots, items as data, and loot tables on enemies and bosses. Health potions drop rarely from trash and always from the Warden; the Warden also drops a Warden's Ration, one per living player, which does nothing at all. The ration is not filler. Player-instanced loot is a separate code path from shared loot -- a distinct entity per owner, filtered per peer in the snapshot encoder -- and the cheapest way to keep that path honest is to have something in the game that exercises it on every boss kill. Item actions ride the input frame rather than becoming new client messages. InputFrame gained BTN_USE, BTN_DROP and a slot byte, which buys the packet-loss redundancy, the replay guard on last_input_tick, ordering against movement on the same tick, and a rate limit of one action per tick -- all of which a separate RPC would have needed bolted back on. The cost is that anything in the frame which must not repeat has to be edge-triggered, since frames are resent and a starved server coasts on the last one it holds. Instanced loot is enforced in NetCodec.encode_snapshot, beside the actor interest radius: a peer is never told another player's copy exists. Hiding it client-side would have been the same mistake as relying on fog to hide enemies. Inventories live on the character and are written to the store on every transaction, so a crash between "picked it up" and "wrote it down" cannot lose or duplicate an item. Anything dropped becomes world-shared whatever it was before, and a potion used at full health is refused rather than spent. tools/diag_loot.tscn covers drop -> snapshot -> pick up -> persist -> use -> drop plus both visibilities on the wire, for the same reason diag_progression exists: bots are poor shots and almost never produce a drop. It asserts each input frame was actually consumed, after an early version silently dropped its first press and every later check passed for the wrong reason. check.sh clean, 266 tests, SMOKE PASS, all three diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+66
-1
@@ -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.
|
||||
|
||||
+62
-7
@@ -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
|
||||
|
||||
+66
-34
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://t2fhc2hd1575
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://de021jelm6hcg
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://d07ue5tca3xt8
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+61
-2
@@ -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
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
+25
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://r142v0ai64i6
|
||||
@@ -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,
|
||||
|
||||
+179
-1
@@ -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)})
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ceonypgg5vaw
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bj5m8a8m1lxdj
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dr3v6m7esntm6
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user