fix(smash): put every kit's first ability on the key a hand rests on - #1012
Merged
Conversation
Twelve of the fifteen kits left hotbar slot 0 empty. A vanilla client selects slot 0 on its own when it reaches the world and no packet changes that, so twelve kits handed a player a bar whose first ability could not be fired until they scrolled. Every ability was present, correctly bound and reachable. No gate noticed, because nothing on the server side had an opinion about which key a hand starts on. The layout was a shift, not a redesign. Every one of the fifteen kits already declared its abilities in ascending slot order with the ultimate last, and the three that were right (Enderman, Skeleton, Slime) were exactly the three whose first ability was numbered 0. So the whole set was one key to the right of where it belonged. There is nothing a per-ability `slot: u8` can be checked against on its own, which is why it drifted: `slot: 1` is wrong only in relation to the rest of its kit, and the doc comment on `AbilitySpec` showed a `slot: 1` that new kit files copied as their first ability. So the field is gone. `KitBuilder::ability` hands out slots in declaration order from 0 and `KitBuilder::ultimate` always takes `ULTIMATE_SLOT`, which deletes fifty-one hand-written numbers and makes the empty first key unreachable rather than merely absent today. Two gates hold what is left: events/smash/tests/hotbar.rs sweeps `kit::registry` and `ability::manifest`, so a kit added tomorrow is covered the moment its module runs. Four invariants: slot 0 is filled, starting abilities run from 0 with no holes, every ultimate sits in the last slot, and no slot is off the end of the bar. It also plays every kit on a real player and reads `kit::hotbar`, which is the function the adapter pushes, because a perfect declaration can still be dropped on instantiation. nix run .#smash-hotbar-e2e stands one client in the hub, changes kit fifteen times and holds the inventory packets it receives against the server's own `/abilities` output. Seventy-six checks. It needs no match, so it is the shortest of the smash gates. Ports default off smash-identity-e2e's. `tools/smash-match.py --ability-slot` defaulted to 3 for Iron Golem's Seismic Slam, which the shift moves to 2. A stale value there used to produce no message at all: an empty slot had no registry entry to disagree with, so the check pressed a key holding nothing, saw no motion, and reported a broken knockback model. It is now a recorded failure the report reads. The same trap was live in the test suite. `modularity.rs` asked slot 3 for a ground requirement and got `Ok(())` from `activate`, which returns that for a slot holding nothing; its cooldown test passed on slot 1 while measuring a different ability than it named. Also folded in: the slot-clash test moves out of `tests/abilities.rs`, which is about what an ability does rather than where it sits, and one line of pre-existing rustfmt drift in `adapter.rs`, in its import of `HotbarItem`, so that `nix run .#ci` is fmt-clean. `Slot` stays a plain component rather than a relation. A slot is a scalar property of an ability, not a reference to another entity: there is no slot entity to point at and no sharing for a relation to earn its keep on, unlike `(PlaysOnCast, sound)` where the sound really is interned and shared. The layout is likewise not stored on the kit, because walking `(Grants, ability)` already derives it. Authored by Claude Code (Opus) at the operator's direction.
Benchmark Results for generalComparing to b5ce627 |
andrewgazelka
force-pushed
the
fix/smash-hotbar-slot0
branch
from
July 28, 2026 09:27
202b677 to
c928075
Compare
Benchmark Results for generalComparing to 205da58 |
Codecov Report✅ All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## main #1012 +/- ##
==========================================
- Coverage 47.17% 47.09% -0.09%
==========================================
Files 238 238
Lines 27097 27054 -43
Branches 1087 1087
==========================================
- Hits 12784 12741 -43
Misses 14077 14077
Partials 236 236
... and 2 files with indirect coverage changes 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Twelve of the fifteen Super Smash Mobs kits left hotbar slot 0 empty and
numbered their abilities from 1. A vanilla client selects slot 0 on its own when
it reaches the world and no packet in the join sequence changes that, so twelve
kits handed a player a bar whose first ability could not be fired until they
scrolled. An operator saw it in a real client: an empty first square, then a
sword, an axe and a block.
Nothing failed. Every ability was present, correctly bound and reachable, and no
gate had an opinion about which key a hand starts on.
The layout, and why it is a shift
Enumerated from source, all fifteen kits already declared their abilities in
ascending slot order with the ultimate last, and the three that were right
(Enderman, Skeleton, Slime) were exactly the three whose first ability was
numbered 0:
Slot 8 was already consistent across the whole roster, so the far-right
convention for the Smash Crystal was understood and simply not applied at the
left end. Every kit's starting abilities were contiguous, just starting from the
wrong number. So the whole set was one key to the right of where it belonged and
the fix is a shift.
The alternative reading, that slot 0 was reserved for a melee weapon not yet
implemented, does not hold:
KitStatsdocuments that a kit hits for the sameamount with any item including a fist, nothing in
kit.rs,server.rsoradapter.rshas any notion of a weapon item, andOp::SetHotbarclears theinventory and refills it purely from abilities, so nothing could ever have
occupied slot 0.
Why the values were not the whole problem
There is nothing a per-ability
slot: u8can be checked against on its own.slot: 1is wrong only in relation to the rest of its kit, and the doc commenton
AbilitySpecshowed aslot: 1(Enderman's Blink, genuinely its secondability) that new kit files then copied as their first. That is the shape that
let twelve kits drift.
So the field is gone.
KitBuilder::abilityhands out slots in declaration orderfrom 0 and
KitBuilder::ultimatealways takesULTIMATE_SLOT. The order a kitfile declares its abilities in is the layout. Fifty-one hand-written numbers
deleted, and the empty first key is now unreachable rather than merely absent
today. Declaring more starting abilities than fit to the left of the ultimate is
an assert at module init rather than an ability nobody can press.
The gates
events/smash/tests/hotbar.rssweepskit::registryandability::manifest,following the pattern
tests/sound.rsset, so a kit added tomorrow is coveredthe moment its module runs. Four invariants, and each earns its place:
failure log reads "Blaze leaves slot 0 empty" rather than a puzzle about sets.
"starts at 0", because a gap is a dead key in the middle of the bar and two or
three abilities have no business being spread across nine keys.
carries across every mob, which is worth nothing if a kit can move it.
one that catches a typo, and
set_hotbarreturns an error the adapterdiscards, so an ability in slot 12 is silently absent rather than loud.
Plus two that read one layer out: playing every kit on a real player and reading
kit::hotbar, which is the functionpush_stale_hotbarscalls, because aperfect declaration can still be dropped on instantiation; and granting a Smash
Crystal, which reaches the bar through a different path from
kit::apply.nix run .#smash-hotbar-e2eproves it over the protocol. One client stands inthe hub (the lobby needs four to start a countdown), changes kit fifteen times,
and holds the
ClientboundContainerSetSlotpackets it receives against theserver's own
/abilitiesoutput: slot 0 filled, keys exactly the ones theregistry names, items matching, no holes, and slot 8 empty until a crystal is
picked up. Seventy-six checks. Framing comes from
tools/client-26.2.pyby wayof
smash-match.py, the same route every other scripted client takes. Portsdefault off
smash-identity-e2e's, so it does not collide with the pair alreadysharing +4000 (ENG-10834).
Watching them fail
Reintroducing the bug as
next_slot: 1:The same change against the e2e gate, which is the one that reads the wire:
Making
next_slotstep by 2 fires the contiguity check only, and raisingULTIMATE_SLOTpastHOTBAR_SLOTSfires the range check only, so eachinvariant was watched failing on its own cause.
What depended on the old numbering
Three things, and two of them would have failed quietly:
tools/smash-match.py --ability-slotdefaulted to 3 for Iron Golem's SeismicSlam, now slot 2. A stale value produced no message at all: an empty slot
has no registry entry to disagree with, so the check pressed a key holding
nothing, saw no motion, and reported a broken knockback model. It is now a
recorded failure the report reads, since the old complaint was appended to a
list already folded into a proof by the time the match phase runs.
tests/modularity.rsasked slot 3 for a ground requirement and gotOk(()),which is what
activatereturns for a slot holding nothing. Its cooldown testpassed on slot 1 while measuring a different ability than it named.
tests/verification.rsdrives four test-local kits by slot. Skeleton, one ofthe three already correct, is deliberately left on slot 1 there: slot 0 is
Barrage, which charges rather than firing, so a bare
use_sloton it neverreaches the cooldown that test is about.
Verdict on the API shape
The values were wrong, but so was the shape. A per-ability slot number with no
notion of the kit's layout is unfalsifiable in isolation, and the doc example
made the wrong value the default thing to copy. Deriving the layout from
declaration order is what makes the class impossible rather than merely gated,
and it costs a kit author nothing: no kit in the roster wanted a
non-contiguous bar.
Slotstays a plain component and not a relation. A slot is a scalar property ofan ability, not a reference to another entity: there is no slot entity to point
at, and no sharing for a relation to earn its keep on, unlike
(PlaysOnCast, sound)where the sound really is interned and shared across abilities. Makingit a relation would mint nine singletons to encode a
u8and turngranted_in_slot, which runs on every right-click, from a component read into atarget lookup. The layout is likewise not stored on the kit: walking
(Grants, ability)already derives it, and a second copy is a thing that candisagree.
Verified locally
Rebased onto
b5ce627, which closed ENG-10817 and made every attribute ofchecksenforced in CI subtractively. Sochecks.smash-hotbar-e2eandchecks.smash-hotbar-e2e-appare enforced the day they land, with no edit tonix/ci/flake-gate.nix, which is whatnix evalconfirms:The
+6000port offset is fornix runonly; the sandboxed check picks a freeport, so it cannot collide with the pair already sharing
+4000(ENG-10834).Run locally on aarch64-darwin, after the rebase:
nix run .#test: 636 passed, 1 skipped (the count moved with refactor: delete three crates and one file that nothing compiles against #1010 and refactor: fixtures and a two-character crate stop being top-level members #1011deleting crates on main; it was 656 before the rebase)
nix run .#lint: cleannix run .#fmt: cleannix run .#smash-hotbar-e2e: RESULT ok, 0 checks failed, 76 passesnix run .#smash-e2e: RESULT a whole match ran at protocol 776, rc=0. Thisis the gate whose
--ability-slotmoves, and it proved knockback from anability on the new slot: P4 took (-2.227, 0.646, -2.227) blocks/tick, which
solves to the same strength horizontally and vertically and so matches the
model rather than a double impulse.
Not done, and worth knowing:
nix run .#mutants) was not run.paths.
smash-e2ewas, because this moves its--ability-slot.ci: enforce the flake checks, by name, instead of exempting all of them #1007 the sandboxed smash gates do now work there.
cargo test -p smashsegfaulted once under parallel test threads, ingame_flow, and passed on the next run and every run with--test-threads=1. Pre-existing flake in flecs registration, not caused bythis change, and unfiled: it is outside this change's scope.
Authored by Claude Code (Opus) at the operator's direction.