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