Stage 3: inventory, ground loot, and two loot visibilities
ci / verify (push) Successful in 48s

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:
claudeandClaude Opus 5 committed 2026-09-04 21:16:15 +02:00
1 parent ded7bf96d5
commit 050b8251a7
50 files changed
+2159 -125

No files matched your search

+15
View File
@@ -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
+81
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://d07ue5tca3xt8