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:
@@ -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
|
||||
Reference in New Issue
Block a user