SIGN IN SIGN UP

core: fan_out_collapsing + the B2 fix — Handled no longer truncates a damage chain (#56)

A4b from the core stabilization audit (#49). Stacked on #55. This is the slice
carrying BEHAVIOUR, so it lands alone rather than buried among mechanical
conversions.

fan_out_collapsing folds handler return values into one HookResult; fan_out is
now a thin wrapper over it (StopAt::Never), so there is genuinely ONE preamble
rather than two that can drift apart again.

THE B2 FIX, verified against the primary source rather than the audit's
paraphrase. ARCHITECTURE.md:78: 'track the max result by precedence. Stop
short-circuits. Handled does NOT short-circuit (a later observer may still want
the event). This short-circuit-vs-observe distinction is exactly why
SourceMod's Action model composed across uncoordinated plugins.'
multiplexer.rs's own handled_does_not_short_circuit test agrees.

dispatch_damage broke at >= Handled, so ONE plugin blocking a hit silently
denied every other plugin's onDamage handler its dispatch for that hit --
exactly the composition the rule protects. Blocking is a decision about the
DAMAGE, not a veto over other observers. It now collapses with StopAt::Stop and
applies zero_current_damage() after the fold.

This INVERTS an existing test: damage_onpre_handled_return_stops_chain
asserted 'chain stopped after Handled' -- the assertion was the bug. Replaced
by damage_onpre_handled_does_not_stop_the_chain plus a new
damage_onpre_stop_return_truncates_the_chain proving the other half of the rule
holds, so this is not a loss of expressiveness. The pre-existing zeroing test
passes unchanged: BLOCK POWER IS PRESERVED, only the truncation is gone.

NOT changed, flagged for a decision: dispatch_chat_message truncates at Handled
too and is the same shape by the letter of the rule, but has a live-proven
justification (a chat line is consumed ONCE -- the per-isolate menu model meant
typing '2' hit a shop menu, a nominate menu and a live vote at once). Preserved
verbatim as StopAt::Handled and documented as the deliberate exception. If chat
should follow the standard rule the fix is for menu/vote handlers to return
Stop, not to special-case the dispatcher -- not a call to make silently inside a
refactor.

dispatch_command_listeners was already the standard rule (OR, no break) ->
StopAt::Stop.

Known gap left deliberately: no Priority::Monitor handling, because these paths
have no priority -- callers synthesised Priority::Normal for every row. That is
why events.d.ts promises ordering the runtime cannot deliver; wiring real
priority through is contract-affecting and belongs with the rest of B2.

cargo test -p s2script-core: 466 passed. CI=1 make ci: green.
G
Gabriel Hirakawa committed
ff99f7580e512e9e52ee14b9392c8c17b45d55b7
Parent: 9c6e18f
Committed by GitHub <noreply@github.com> on 8/1/2026, 6:07:24 AM