Skip to content

feat(smash): a screen that says what the game already knows - #1018

Merged
andrewgazelka merged 1 commit into
mainfrom
feat/smash-feedback
Jul 28, 2026
Merged

feat(smash): a screen that says what the game already knows#1018
andrewgazelka merged 1 commit into
mainfrom
feat/smash-feedback

Conversation

@andrewgazelka

Copy link
Copy Markdown
Member

Super Smash Mobs enforced ability cooldowns, recorded who smashed whom and ran a
five-phase match, and a player could see almost none of it. The cooldown existed
only as a line of red text after a press had already been refused. The kill
credit reached a broadcast chat line read by everybody except the person it
happened to. BossEvent and SetSubtitleText were sent zero times by this
server.

Three surfaces, one rule: the game already knows, so say so before it matters
rather than after.

The experience bar is the recharge of the slot you are holding

Full with no number when the ability is ready, filling from empty with the whole
seconds beside it while it is not, and empty with no number for a slot holding
nothing. Those two numbers together are what make the third state expressible:
neither of the others can produce an empty bar with no count.

Filling and not draining. Vanilla fills that bar as experience accumulates, so a
full bar already reads as "you have the thing" to anybody who has played
Minecraft. Draining would put it at its fullest at the exact moment the ability
cannot be used, which inverts the one fact it is there to carry.

It follows a mirrored SelectedSlot and not the last ability fired, which reads
identically until a player holds a different item, and it floors rather than
rounds so a bar that reads full and an ability that is ready are the same
statement rather than nearly the same one.

The level number is the whole seconds left, rounded up. The bar is the
glanceable half and is deliberately imprecise; the number answers the other
question, which is not "am I ready" but "how long until I am".

The bar across the top is the knockback percentage

The signature read of the mode, and no client draws it: hearts say how much
damage you have left to take, percent says what the next hit will do with it.

It is not invented. knockback::strength's health term is
1 + 0.1 * missing_health, so the percentage is that term with the one taken
off and multiplied out: 0% fresh, 100% where a hit sends you twice as far. A
test solves the claim against strength rather than restating percent's own
arithmetic, so changing one without the other fails. Green, yellow and red sit
at the multipliers those bands mean, not at numbers that felt right.

Outside a match the same bar carries whatever the player should be watching
instead: how many more people the lobby needs, then how long is left, then the
result. One bar rather than two. A match-clock bar was the alternative and was
dropped, because a twenty minute timeout nobody has reached is not something a
player acts on and it would have spent the other half of the only
always-visible strip on the screen saying so.

Titles punctuate the match, and a death names the killer

The last three seconds of the prepare timer get a digit. The start gets a word.
The end names the winner, in the title, in the boss bar and in chat. A death
names the killer in the subtitle, which is the whole reason it is a title now:
until this, the middle of your own screen carried a life count and no answer at
all to the only question a player asks there. The credit is not new, it simply
had nowhere to be read.

A title and its subtitle travel as one value. The protocol makes them separable
and the separation is a trap: ClientboundSetSubtitleTextPacket only stores a
line and the next title is what draws both, so a subtitle written second appears
under the wrong title, and one nobody cleared outlives the title it was written
for. Carrying them together lets the adapter always write times, then subtitle,
then title, and tools/hud-check.py reads that order back off the wire, which
is the only place it is observable at all.

What the wire gate found that no unit test could

Two "3, 2, 1" sequences back to back: ['3', '2', '1', '3', '2', '1', 'GO!'].
Both counting phases cross those boundaries and both tick out loud, so a window
on both draws the digits twice with a teleport in the middle. Digits are now the
prepare timer's alone.

And 3,065 boss bar packets in thirty seconds for eight clients, because progress
is continuous in three of the five phases. Quantising both surfaces to
sixty-four steps, which is finer than a 182 pixel bar can draw, took it to
1,135.

Deliberately left

Each for a reason rather than for time.

  • Nametags carry no percentage and no last-life colour. That needs
    SetPlayerTeam plumbing a sibling change is already near, and the sidebar
    already colours a name by its life tier from the same table.
  • The sidebar carries no percentage. Its own documentation is about not
    redrawing a panel that changes continuously, and a percentage moves under
    every kit's health regeneration; quantising it enough to stop the churn would
    put a number on screen that is not the number.
  • The experience bar shows a cooldown and not a charge or an energy bar. One
    bar carries one quantity, and two that both read "filling towards go" cannot
    be told apart on it.
  • A match timer. See above.

One gap in the brief turned out to be already closed: lives.rs has broadcast
X was smashed by Y! since the death path was written, and smash-match.py
reads it. What was missing was the victim's own screen, which is what the
subtitle is.

Verified

Local only, per ENG-10817.

  • nix run .#test -- 677 passed, 1 skipped
  • nix run .#lint -- clean
  • nix run .#fmt -- clean
  • nix run .#smash-hud-e2e -- 25 checks, 0 failed, eight real clients through a
    whole countdown and into a match
  • nix run .#smash-e2e -- RESULT: a whole match ran at protocol 776, rc=0

Every guard was broken and watched to fail before being trusted. Selected
output:

quantise now rounds to nearest
  a_recharging_bar_never_reads_full: a 7s cooldown read as full with 0.05s left

the meter now reads slot 3 whatever is held
  changing_the_held_slot_changes_which_cooldown_the_bar_shows:
    left: Some(Experience { progress: 0.0, level: 7 })  right: Some(Experience { progress: 1.0, level: 0 })

the diff that suppresses an unchanged screen is gone
  an_unchanged_screen_sends_nothing:  left: 40  right: 1

both counting phases get digits again
  the_countdown_puts_a_number_on_every_screen_exactly_once:
    left: ["3", "2", "1", "3", "2", "1"]  right: ["3", "2", "1"]

the death screen no longer names the killer
  a_death_names_the_killer_under_the_life_count:  left: None  right: Some("Smashed by Andrew")

percent is now plain damage taken, not the knockback multiplier
  a_percentage_is_how_much_further_the_next_hit_sends_you:
    at 18 health the percentage says 10% but the model launched 0.93378156 against 0.85596645

the title is now written before its subtitle
  FAIL every title arrives as times, then its subtitle, then itself:
    3 title(s), 3 out of order [[('subtitle', 'Get ready'), ('times', (0, 20, 0)), ('title', '2')], ...]

Also filed

ENG-10852: cargo test -p smash segfaults about once in a hundred runs
inside ecs_init, because cargo test runs a thread per test and every test
opens a World. Pre-existing, measured at 1 in 160 on origin/main, and invisible
to nix run .#test because nextest forks per test. Ruling my own change out of
it cost about forty minutes.

One thing outside the feature came with it: smash-hotbar-e2e landed with a
literal port offset of 6000, which is completions-e2e's, and a literal is
exactly what the new e2ePortsDistinct eval guard cannot see. Both it and this
gate are now in e2eOffsets.


Written by Claude (Opus 5) with human direction.

Super Smash Mobs enforced ability cooldowns, recorded who smashed whom and
ran a five-phase match, and a player could see almost none of it. The
cooldown existed only as a line of red text after a press had already been
refused; the kill credit reached a broadcast chat line read by everybody
except the person it happened to; and BossEvent and SetSubtitleText were sent
zero times by this server.

Three surfaces, one rule: the game already knows, so say so before it matters
rather than after.

The experience bar is the recharge of the ability in the slot you are
holding. Full with no number when it is ready, filling from empty with the
whole seconds beside it while it is not, and empty with no number for a slot
holding nothing, which is a third state neither of the others can produce.
Filling and not draining, because vanilla fills that bar as experience
accumulates and a full bar therefore already reads as "you have the thing";
draining would put it at its fullest at the exact moment the ability cannot
be used. It follows a mirrored SelectedSlot rather than the last ability
fired, and floors rather than rounds, so a bar that reads full and an ability
that is ready are the same statement.

The bar across the top is the knockback percentage, which is the read the
mode is played on and which no client draws. It is not invented: the
knockback model's health term is 1 + 0.1 * missing_health, so the percentage
is that term with the one taken off, and a test solves it against `strength`
rather than restating its arithmetic. Green, yellow and red at the
multipliers those bands mean. Outside a match the same bar carries what the
player should be watching instead: how many more people the lobby needs, then
how long is left, then the result.

The titles punctuate the match. The last three seconds of the prepare timer
get a digit, the start gets a word, the end names the winner, and a death
names the killer in the subtitle -- which is the whole reason it is a title
now, because until this the middle of your own screen carried a life count
and no answer to the only question a player asks there.

Title and subtitle travel as one value. The protocol makes them separable and
the separation is a trap: the subtitle packet only stores a line and the next
title is what draws both, so a subtitle written second appears under the wrong
title and one never cleared outlives the title it was written for. Carrying
them together lets the adapter always write times, then subtitle, then title,
and `tools/hud-check.py` reads that order off the wire, which is the only
place it is observable.

Both surfaces are diffed against what the client was last told, initialised
to the state a fresh client is already in, and quantised to sixty-four steps.
Measured by the new gate: 3,065 boss bar packets in thirty seconds before,
1,135 after, for a bar 182 pixels wide.

What the gate found that no unit test could: two "3, 2, 1" sequences back to
back, because both counting phases cross those boundaries. Digits are now the
prepare timer's alone; the lobby's sixty second wait belongs on the bar.

Deliberately left, and each for a reason rather than for time:

* Nametags carry no percentage and no last-life colour. That needs
  SetPlayerTeam plumbing a sibling change is already near, and the sidebar
  already colours a name by its life tier from the same table.
* The sidebar carries no percentage. Its own documentation is about not
  redrawing a panel that changes continuously, and a percentage moves under
  every kit's health regeneration; quantising it enough to stop the churn
  would put a number on screen that is not the number.
* The experience bar shows a cooldown and not a charge or an energy bar. One
  bar carries one quantity, and two that both read "filling towards go"
  cannot be told apart on it.
* No match timer. A twenty minute timeout nobody has reached is not something
  a player acts on, and it would have spent the other half of the only
  always-visible strip on the screen saying so.

One thing outside the feature came with it. `smash-hotbar-e2e` landed with a
literal port offset of 6000, which is `completions-e2e`'s, and a literal is
exactly what the new `e2ePortsDistinct` eval guard cannot see. Both it and
this gate are now in `e2eOffsets`, so the guard covers every gate rather than
most of them.

Verified locally: `nix run .#test` (677 passed), `nix run .#lint`,
`nix run .#fmt --check`, and `nix run .#smash-hud-e2e` (24 checks, eight real
clients through a whole countdown and into a match), and `nix run .#smash-e2e`
for regressions. Every guard was broken and watched to fail before being
trusted.
@andrewgazelka
andrewgazelka merged commit 68c5429 into main Jul 28, 2026
11 checks passed
@github-actions github-actions Bot added the feat label Jul 28, 2026
@andrewgazelka
andrewgazelka deleted the feat/smash-feedback branch July 28, 2026 10:02
@github-actions

Copy link
Copy Markdown

Benchmark Results for general

ray_intersection/aabb_size_0.1                     [  18.7 ns ...  18.7 ns ]      +0.16%
ray_intersection/aabb_size_1                       [  18.8 ns ...  18.8 ns ]      -0.02%
ray_intersection/aabb_size_10                      [  18.7 ns ...  18.8 ns ]      +0.24%
ray_intersection/ray_distance_1                    [   1.3 ns ...   1.3 ns ]      +1.46%
ray_intersection/ray_distance_5                    [   1.3 ns ...   1.3 ns ]      +0.13%
ray_intersection/ray_distance_20                   [   1.3 ns ...   1.3 ns ]      -1.21%
overlap/no_overlap                                 [  15.9 ns ...  15.9 ns ]      +0.08%
overlap/partial_overlap                            [  15.8 ns ...  16.0 ns ]      +1.12%*
overlap/full_containment                           [  14.5 ns ...  14.5 ns ]      +0.02%
point_containment/inside                           [   5.4 ns ...   5.4 ns ]      -0.07%
point_containment/outside                          [   5.7 ns ...   5.7 ns ]      -0.04%
point_containment/boundary                         [   5.4 ns ...   5.4 ns ]      -0.05%

Comparing to 23395b9

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.36975% with 122 lines in your changes missing coverage. Please review.
✅ Project coverage is 47.58%. Comparing base (23395b9) to head (49811ef).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
events/smash/src/adapter.rs 0.00% 106 Missing ⚠️
events/smash/src/mirror.rs 0.00% 6 Missing ⚠️
events/smash/src/input.rs 0.00% 5 Missing ⚠️
events/smash/src/server.rs 83.33% 3 Missing ⚠️
events/smash/src/module/hud.rs 99.61% 1 Missing ⚠️
events/smash/src/server/mock.rs 97.91% 0 Missing and 1 partial ⚠️
@@            Coverage Diff             @@
##             main    #1018      +/-   ##
==========================================
+ Coverage   47.16%   47.58%   +0.42%     
==========================================
  Files         242      243       +1     
  Lines       27177    27608     +431     
  Branches     1096     1118      +22     
==========================================
+ Hits        12817    13137     +320     
- Misses      14124    14235     +111     
  Partials      236      236              
Files with missing lines Coverage Δ
events/smash/src/lib.rs 44.18% <100.00%> (+1.32%) ⬆️
events/smash/src/module/knockback.rs 100.00% <100.00%> (ø)
events/smash/src/module/lives.rs 97.83% <100.00%> (-0.14%) ⬇️
events/smash/src/module/lobby.rs 99.13% <100.00%> (+0.03%) ⬆️
events/smash/src/module/player.rs 96.15% <100.00%> (+0.10%) ⬆️
events/smash/src/module/hud.rs 99.61% <99.61%> (ø)
events/smash/src/server/mock.rs 97.10% <97.91%> (-1.79%) ⬇️
events/smash/src/server.rs 79.59% <83.33%> (+2.17%) ⬆️
events/smash/src/input.rs 0.00% <0.00%> (ø)
events/smash/src/mirror.rs 0.00% <0.00%> (ø)
... and 1 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

andrewgazelka added a commit that referenced this pull request Jul 28, 2026
…t has two

#1018 put one boss bar on the wire, hand rolled in a game event's adapter.
This is the API it should have been, and the two bars the operator asked for
on top of it.

A bar is an entity, its audience is `(ShownTo, player)` edges, and what each
viewer was last told is the data on `(Sent, viewer)`. That last choice is
what removes the two cases that are usually bugs: a mid-match joiner has no
pair, so the next tick sends them `Add`, and every way a bar stops being
shown ends with that pair going away, which one `OnRemove` observer turns
into a `Remove`. There is no join or leave handler in this change.

One field moves and it is that field's narrow operation; two or more and it
is a single fresh `Add`, which is both atomic and smaller than the updates it
replaces. The wire gate found that one: a lobby bar becoming a countdown was
briefly a countdown in the lobby's blue.

`/serverload`, Admin, opt in, puts the host's own CPU and RSS on two bars.
CPU is `getrusage`, not `ps`, so the first sample reports nothing rather than
a lifetime average. Both are drawn against the point the machine stops being
able to give more and print that ceiling beside the reading, so the fill is
the quotient of the two numbers in the bar's own label and `hud-check.py`
divides them to check it.

`smash-hud-e2e` goes from 25 checks to 48, including one that reads every
boss bar packet of the whole run and asserts none of them said anything the
client already knew. It also fixes a check #1019 left red on main.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
…t has two

#1018 put one boss bar on the wire, hand rolled in a game event's adapter.
This is the API it should have been, and the two bars the operator asked for
on top of it.

A bar is an entity, its audience is `(ShownTo, player)` edges, and what each
viewer was last told is the data on `(Sent, viewer)`. That last choice is
what removes the two cases that are usually bugs: a mid-match joiner has no
pair, so the next tick sends them `Add`, and every way a bar stops being
shown ends with that pair going away, which one `OnRemove` observer turns
into a `Remove`. There is no join or leave handler in this change.

One field moves and it is that field's narrow operation; two or more and it
is a single fresh `Add`, which is both atomic and smaller than the updates it
replaces. The wire gate found that one: a lobby bar becoming a countdown was
briefly a countdown in the lobby's blue.

`/serverload`, Admin, opt in, puts the host's own CPU and RSS on two bars.
CPU is `getrusage`, not `ps`, so the first sample reports nothing rather than
a lifetime average. Both are drawn against the point the machine stops being
able to give more and print that ceiling beside the reading, so the fill is
the quotient of the two numbers in the bar's own label and `hud-check.py`
divides them to check it.

`smash-hud-e2e` goes from 25 checks to 48, including one that reads every
boss bar packet of the whole run and asserts none of them said anything the
client already knew. It also fixes a check #1019 left red on main.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
…t has two (#1026)

`BossEvent` reached the wire for the first time in #1018, as one bar,
hand
rolled in a game event's adapter: one UUID per player, `Add` for every
change,
and a diff in the game half to stop it resending. That was correct and
it was
measured, and it did not survive the next ask, which was two more bars
carrying
something that is not a game concept at all.

## A bar is an entity and its audience is a set of edges

```rust
let bar = world
    .entity()
    .add(BossBar::id())
    .set(Title(Text::text("CPU 240% of 1600%").color(NamedColor::Aqua)))
    .set(Progress(0.15))
    .add((ShownTo, operator));
```

and later `bar.set(Progress(0.31))`, which sends one `UpdateProgress` to
each
viewer and nothing else. `smash`'s `Server::set_boss_bar` is now four
`set`s on
one of these, and the packet it used to build is gone.

The load-bearing choice is not the components; it is where the
per-viewer sent
state lives. `Sent` is the data on the `(Sent, viewer)` pair, holding
exactly
what that client was last told, and it is what makes the two cases that
are
usually bugs stop being code:

* **Joining.** A player who was not there has no `(Sent, bar)` pair, so
the
  next tick sends them `Add`. There is no join handler in this change.
* **Leaving.** A bar is destroyed, a player disconnects, or an audience
shrinks. All three end with the pair going away -- flecs does the first
two
itself under `(OnDeleteTarget, Remove)` -- and one `OnRemove` observer
on
`(Sent, Wildcard)` sends the `Remove`. Teardown cannot leak, because the
fact
that says "this client has this bar" and the thing that gets cleaned up
are
  the same fact.

There is no explicit join or leave path anywhere in `boss_bar.rs`. That
was the
test of whether the shape was right.

## The change the wire gate found

A change of state usually moves several fields at once, each operation
is its
own packet, and between the first and the last the client is holding a
bar that
is half one state and half the next. There is no ordering that fixes it:
put
the title last and a countdown ticking over draws the next second's fill
under
this second's number; put it first and a lobby bar becoming a countdown
is a
countdown in the lobby's blue.

`smash-hud-e2e` caught the second of those as a real failure -- one of
#1018's
own checks, `and it is yellow, not the lobby's blue`, against a bar that
was
briefly exactly that. So one field moving is that field's narrow
operation, and
two or more is a single fresh `Add`. `Add` is *also* the smaller packet:
every
operation repeats the sixteen byte id, so two updates already cost more
than
the one that carries all four fields, and `BossHealthOverlay.add` is a
`put`
into a map keyed on the id, so it replaces the bar in place.

Packet count is unchanged from #1018's 1,135 -- both send one packet per
change
-- and each packet is now at most the size of the `Add` it replaced.

## The two bars that carry the host

`/serverload`, Admin, opt in. Fifteen people in a lobby staring at
server
telemetry is a strange default and three stacked bars is most of the
strip.

**CPU is `getrusage(RUSAGE_SELF)`, not `ps`.** `ru_utime + ru_stime` is
a
cumulative counter, so a rate over a window is the only reading it can
give and
the first sample reports that it has nothing yet. `ps` answers the
lifetime
average instead, which is a different question wearing the same units: a
server
busy for a minute an hour ago reads busy under `ps` forever.

**Memory is RSS**, which is what the kernel counts when it decides who
to kill.
`/proc/self/statm` field one on Linux, `proc_pidinfo`/`PROC_PIDTASKINFO`
on
darwin. The Linux path cannot run on the box the gates run on, so the
parser is
a pure function over a string with a fixture whose seven fields all hold
different numbers -- an off-by-one cannot pass it.

### The bar that has no natural full

Neither quantity is a fraction. A process can use more than one core's
worth of
CPU, and it can allocate until the machine refuses. So both are drawn
against
the point where the machine stops being able to give more -- every core
it has,
every byte of physical memory -- and both print that ceiling beside the
reading:

```
CPU 240% of 1600%
MEM 3.20 GiB of 128.0 GiB
```

which turns the choice into something checkable rather than a matter of
taste:

> **the fill is the quotient of the two numbers in the bar's own
label.**

`hud-check.py` reads both numbers off the wire and divides. A ceiling
nobody
printed cannot satisfy that, and neither can one invented to make the
bar look
busy. It is also what keeps the label unnormalised: the honest figure is
the
numerator, and the fill is that same fact taken against the denominator
standing next to it. 200% is two cores, which is what `top` says and
what the
question meant.

Cores come from `available_parallelism`, not the raw processor count,
because
in a container the honest ceiling is what the container is allowed.

## The gate

`nix run .#smash-hud-e2e`, 48 checks, up from 25.

`decode_boss_event` read only `Add` and returned `None` for everything
else,
because `Add` was all the server sent. Left alone against this server it
would
not have failed -- it would have reported that no boss bar ever arrived,
which
is a silent pass. It now decodes all six operations into a model of the
client's own `BossHealthOverlay`, so #1018's twenty-five checks ask the
same
questions of the same states, and the operations are kept separately as
the
only place the diffing is visible at all.

One check that was already there was also red before this branch touched
anything. #1019 moved `LobbyConfig::min_players` from four to two, and
the hub
bar's `1/4` was written into `hud-check.py` as a literal, so the gate
has been
failing on `main` against a server saying `1/2`. #1019 rewrote six
`tests/lobby.rs` tests for exactly this reason and the one in `tools/`
was
missed, because nothing in CI runs the wire gates. It now reads the
denominator
off the bar and additionally checks that the fill under the label is
`1/needed`, which is a stronger claim than the literal it replaces and
cannot
go stale again. Filed as ENG-10878.

The new checks, beyond the two bars' own numbers:

* An update sends only the update, over **every** boss bar packet of the
whole
run rather than at one moment. Three rules -- no packet says anything
the
client already knew, a whole bar is only resent where two or more fields
  moved, nothing arrives for a bar the client was never given -- checked
  against 849 packets in the run below.
* A mid-match joiner gets the bar for the match it walked into, as an
`Add`
  carrying the whole thing.
* A second viewer joining a *running* bar's audience gets it under the
same id
the first one holds. The load bars are the only ones in the game with an
  audience of more than one person, so they are the only place this is
  observable.
* Leaving that audience sends a `Remove` to that viewer and nothing to
anybody
  else.
* A viewer disconnecting with a bar on their screen leaves the server
drawing
  everybody else's.

## Broken, and watched to fail

Every new guard was inverted and run. Selected output.

### The parser reads `/proc/self/statm`'s first field instead of its
second

```
statm_reads_the_resident_field_and_not_a_neighbour
  left: Some(287072256)  right: Some(27807744)
a_statm_line_that_is_not_one_reads_as_nothing
  left: Some(287072256)  right: None
```

The fixture's seven fields all hold different numbers, so the off-by-one
is a
wrong answer rather than a coincidence, and the second test caught it
too.

### The CPU label is normalised, which is the whole point of the ask

```
the_cpu_label_is_unnormalised_and_the_fill_is_not
  left: "CPU 15% of 100%"  right: "CPU 240% of 1600%"
```

and on the wire, with the same edit:

```
FAIL the CPU label is unnormalised, so its ceiling is every core and not one: CPU 0% of 100%
```

### The first CPU sample answers with the lifetime average, the way `ps`
does

```
the_first_sample_reports_nothing_rather_than_an_average
  left: Some(90.0)  right: None
```

### `Sent` is cleaned up with `Delete` rather than `Remove`

```
a_viewer_leaving_removes_only_their_own_record
  one viewer leaving destroyed the bar
```

Which is the whole teardown design failing in the direction that would
be
hardest to notice in play: one person quits and the bar goes off
everybody's
screen.

### Every change resends the whole bar

```
FAIL and a whole bar was only ever resent where two or more fields moved at once:
  827 of 872 [('H1', '6879705f', ['title']), ('H1', '6879705f', ['title']), ...]
FAIL a new CPU reading that moves only the label costs one packet carrying only
  the label: ['add', 'add', 'add', 'add', 'add']
```

### A client is recorded as having a bar it was never sent

```
FAIL and nothing arrived for a bar the client had never been given:
  39 of 981 [('H1', '6879705f', 'update_name'), ...]
FAIL the hub puts a boss bar up saying what it is waiting for: []
```

The second line is what this failure looks like from the outside, and it
is why
the first one exists: a bar that never arrives is indistinguishable from
a
server that never drew one, until something is watching for updates to a
bar
nobody has.

### The id is per viewer rather than per bar, and the teardown observer
is gone

```
FAIL a viewer joining a running bar's audience is sent it under the same id the
  others hold: ['...0584', '...5816a', '...5816d', '...58784']
FAIL with a Remove, while the viewer who is still watching keeps theirs:
  [] / ['...057e', '...5787e', '...57e6a', '...57e6d']
```

Four ids where there should be two, because every viewer minted its own,
and no
`Remove` at all where a viewer had just left the audience.

## Verified

Local only, per ENG-10817.

* `nix run .#test` -- 704 passed, 1 skipped
* `nix run .#smash-hud-e2e` -- 48 checks, 0 failed
* `nix run .#fmt` -- clean
* `nix run .#lint` -- **one failure, and it is not this branch's**:

```
error: unused import: `flecs_ecs::prelude`
   --> events/smash/tests/abilities.rs:595:9
```

Reproduced against a detached worktree at `origin/main` with its own
`CARGO_TARGET_DIR`, running the same command `flake.nix:182` runs, with
no part
of this branch present:

```
$ CARGO_TARGET_DIR=/tmp/mainlint-target cargo clippy --all-targets --all-features -- -D warnings
error: unused import: `flecs_ecs::prelude`
   --> events/smash/tests/abilities.rs:595:9
rc=101
```

Filed as ENG-10890 rather than fixed here, because it belongs to #1023
and not
to this change. Worth knowing while chasing it: plain `cargo clippy -p
smash
--tests` over the same tree exits 0, because `unused_imports` is only a
failure
under the `-- -D warnings` the flake adds -- which is how it got past
#1023's
own check, and how it briefly looked like mine.

## Deliberately not built

* **No synthetic load past 100% on the wire.** The gate proves the fill
stays a
fraction, that the ceiling is every core, and that the fill is the
quotient
of the label's own two numbers; it does not observe a reading above one
core's worth, because forcing the server process over a core from a test
client would mean a burn command in production code. The unnormalised
case is
a unit test with the numbers injected: 2.4 cores of 16 reads `CPU 240%
of
  1600%`, and 2.4 cores of 1 reads `CPU 240% of 100%` with a full bar.
* **The `/proc/self/statm` fixture is written to `proc(5)`, not captured
off a
running host.** The gates run on darwin. What the fixture does prove is
the
field index, which is the failure that would ship, because every field
holds
  a different number.
* **The sidebar is still hand rolled in smash's adapter.** It has the
same
argument as the boss bar and it should follow, but porting it is a
second
  change and this one already moves a gate.
* **`ShownToEveryone` has no caller.** It is in the API because
"everyone" is a
rule rather than a list -- a player who connects a minute from now is in
it --
and it is covered by the drive system's own path, but nothing in the
tree
  shows a bar to everybody yet.

## Also filed

**ENG-10878**: `smash-hud-e2e` was red on `main` before this branch, as
above.
Fixed here. The follow-up worth having is a job that runs the eight
gates in
`e2eOffsets`, because a change that breaks only one of them is invisible
today
until somebody runs it by hand.

**ENG-10871**: `/perms set <player> Admin` is gated at `Normal`, so any
player
who can connect can make themselves Admin. The derive gates a whole clap
enum
at once, so `Get` and `Set` cannot differ. This gate depends on the hole
-- it
promotes its own clients, because there is no other way for a test
client to
reach an Admin command -- so closing it needs a way to seed a group at
startup
landed alongside. `hud-check.py` names the ticket at the line that
depends on
it.

---

Written by Claude (Opus 5) with human direction.
andrewgazelka added a commit that referenced this pull request Jul 28, 2026
#1027)

## Effect

`nix run .#smash-e2e` passes again, all 15 kits. It has been red since
#1019.

## What broke

#1019 moved `LobbyConfig::default()` from 4/8 to 2/4, which the operator
asked for. `tools/smash-match.py` had those numbers written into it a
second time -- as `max(1, self.args.clients - 1)`, as "`min_players` is
four" in its docstring, and as "one short of the lobby's minimum" at the
call site -- so the sweep's three clients went from below the threshold
to above it. The countdown started, the match committed, and every later
kit change was refused with `You cannot change kit once the game has
started.`

Reproduced on e4bbcc2 before changing anything:

```
     0.60s       PROVED lobby started a match: server announced the countdown with 3 players connected
     0.68s       PROVED four in play: 3 clients hold a Login packet: P1=67110240, P2=1377, P3=1378
    30.79s [P3 ] <- chat: §cYou cannot change kit once the game has started.
    45.78s [P3 ] <- chat: §cYou cannot change kit once the game has started.
    60.83s [P1 ] <- chat: §cYou cannot change kit once the game has started.
```

## It was not the minimum that fired, and that decides the fix

The countdown started 0.6 seconds in, not at `min_players`.
`countdown_for` checked the three-quarters band before the minimum, so
at 2/4 three players satisfy `3 * 4 >= 4 * 3` and take the 30-second
band.

So `min_players - 1` was never a sound expression of "a roster that
cannot start a countdown". The real condition depends on `full_players`
too. At 4/8 the subtraction worked by luck.

## Why not read the threshold off the wire

#1018 puts `Waiting for players {n}/{min_players}` on a boss bar, and a
harness that reads its own limit off that packet cannot disagree with
the server. I rejected it as the fix, because it cannot be one. The
sweep needs two clients to prove `hurts_target` and to call
`wound_attacker` for `heals_caster`; at `min_players: 2` the number it
would read off the wire is one, and a one-client sweep goes green while
proving less. Observation cannot manufacture headroom, only control can.
The bar also advertises only `min_players`, which per the section above
is not the number that decides this.

## So the gate declares the lobby, and the harness checks it got it

- `LobbyConfig::from_env()` reads `SMASH_MIN_PLAYERS` and
`SMASH_FULL_PLAYERS`, in the `SMASH_`-prefixed namespace
`hyperion_event_runner::run` already reads the address and certificates
from. Not added to the shared `Args`, which bedwars would then carry and
never read.
- `nix/e2e.nix`'s `mkCheck` gained `serverEnv`. `smashGateLobby` in
`flake.nix` declares 4/8 and a sweep roster of 3 in one place and feeds
both the app and the check.
- `smash-match.py` takes `--sweep-clients` and subtracts nothing. The
docstring no longer says four.

4/8 and not just a higher minimum, because `full_players` is what gates
the three-quarters band. These are the pre-#1019 numbers, under which
three players start nothing even by the old band order, so the gate does
not depend on the `countdown_for` change below.

## The guard is the repro

`hub_only` fails the run if the lobby leaves the hub while the sweep
needs it, checked before the sweep and again before each kit. It reads
the phase off the server's own broadcasts rather than recomputing a
threshold, so it stays right whatever the numbers become and is not a
second copy of them.

Broken deliberately, by pointing `smashGateLobby.env` at production's
2/4:

```
     0.72s       SWEEP ABANDONED the lobby left the hub before the sweep began. 3 clients is enough for this server to start a countdown, so kits can no longer be changed and nothing further would be proved. Either --sweep-clients is too many for this lobby, or the server needs a higher SMASH_MIN_PLAYERS/SMASH_FULL_PLAYERS.
     0.72s       RESULT: 10 of 12 steps did not happen
rc=1
```

571 seconds and a hang become 0.72 seconds and a cause. Restored to 4/8
afterwards.

Breaking it is also what found a real bug in it. The first version
stopped the run but only recorded its reason in `sweep_failures`, which
`sweep` prints at the end of a sweep it no longer reached -- so the run
failed with the reason nowhere in the transcript, and then went on to
run a mostly-passing match underneath the failure. It now logs directly
and returns straight to the report.

## countdown_for checks the minimum first

Fixed here rather than filed, because it is a reordering in a file this
PR already touches and its failure mode is silent. `min_players` was a
partial lie: at `min_players: 4, full_players: 4`, three players started
a countdown under a stated minimum of four.

Provably inert for both configurations anyone runs.
`the_live_configurations_are_unaffected_by_the_minimum_check` keeps the
old three-branch order in the test and asserts the new one agrees with
it at every count from 0 to `full_players * 3`, for 2/4 and 4/8.
`a_lobby_never_starts_below_its_own_minimum` is the case that would have
caught it, over every config from 1/1 to 12/12.

## Checks

`cargo nextest run -p smash`: 257 passed. `cargo clippy -p smash
--all-targets`: clean. `cargo fmt --check`: clean.

`nix run .#smash-e2e`, all 15 kits and all 12 claims:

```
 254.43s       === what the match proved ===
 254.43s         proved      the server published its ability registry
 254.43s                     51 abilities across 15 kits (Blaze, Chicken, Cow, Creeper, Enderman, Guardian, Iron Golem, Skeleton, Sky Squid, Slime, Snowman, Spider, Wither Skeleton, Wolf, Zombie), every one of them declaring what a client would see
 254.43s         proved      every declared ability did what it declared
 254.43s                     51 abilities fired by a real client, each one checked against the effects its own registry entry names
 254.43s         proved      every declared ability was heard
 254.43s                     51 abilities fired by a real client, each one answered by a ClientboundSoundPacket carrying the vanilla sound event its own registry entry names
 254.43s         proved      cooldowns refused a second use
 254.43s                     47 abilities answered a second right-click inside their cooldown with 'That ability is recharging.' on the action bar
 254.43s         proved      four in play
 254.43s         proved      lobby started a match
 254.43s                     server announced the countdown with 4 players connected
 254.43s         proved      kits equipped
 254.43s         proved      arena is a committed map
 254.43s         proved      knockback from an ability
 254.43s         proved      a hit was heard where it landed
 254.43s         proved      life lost and respawned
 254.43s         proved      match ended, back in the hub
 254.43s       RESULT: a whole match ran at protocol 776
rc=0
```

The sweep is 73 seconds for all 51 abilities, not the 571 seconds the
broken run took: that figure was time spent on refused kit changes, not
real work. The check's 480s timeout has room.

## Not done, and one thing found

I did not run the `smash-e2e` **check** derivation, only the app. CI
covers it and the timing above says it fits.

ENG-10889: `tools/smash-selector.py` has this same defect twice,
`FULL_PLAYERS = 8` with a comment citing `LobbyConfig::default` and
"Three, which is one short of `min_players`" above its own `connect(3)`.
`smash-selector-e2e` is likely broken by #1019 in exactly the way this
gate was. Not run and not fixed here. `hotbar-check.py:22` and
`smash-map-check.py:1141` have stale prose about the same numbers but
still behave.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant