fix(chess): fire preset lifecycle hooks on game.presets sync

PredictionManager.applyPresets was calling activePresets.replaceAll
directly, bypassing preset onActivate / onDeactivate hooks on the
client engine. piece-hp installs Hp facts from onActivate, so in
multiplayer the client knew the preset was active but had no Hp
facts to render \u2014 the overlay silently drew nothing.

Routed through setActivePresets. Both server and client now run
the same idempotent onActivate, arriving at the same state without
serializing Hp facts over the wire. applyFullState (snapshot path)
still uses bare replaceAll because facts in the snapshot already
include any preset-installed attributes.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 15:57:53 -06:00
commit 2739cc2689
No known key found for this signature in database

View file

@ -130,10 +130,23 @@ export class PredictionManager {
* previously-legal optimistic moves, and re-validating them here would
* duplicate server logic. Simpler to let the next user action
* re-predict against the freshly-synced base.
*
* We route through `setActivePresets` (not bare `activePresets.replaceAll`)
* so preset `onActivate` / `onDeactivate` lifecycle hooks fire on the
* client's base engine. That's essential for rules like `piece-hp`
* which install per-piece state (Hp facts) from onActivate without
* this the client would know the preset is active but have no Hp
* facts to render, because `game.presets` carries only the
* activation list, not the resulting facts.
*
* Idempotence guarantee: preset hooks are expected to be idempotent
* (piece-hp.onActivate only inserts Hp when absent). Server and
* client run the same hook logic, so both arrive at the same state.
* The next `game.state` or `game.delta` reconciles any drift.
*/
private applyPresets(payload: GamePresetsPayload): void {
try {
this.baseEngine.activePresets.replaceAll(payload.activations);
this.baseEngine.setActivePresets(payload.activations);
} catch {
// A bad set from the server shouldn't crash the client; the server
// already validated, so this branch is defensive only. We clear