]> git.cworth.org Git - lmno.games/commitdiff
An attempt at catching some state drift bugs (and adding logging as well)
authorCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 22:13:07 +0000 (18:13 -0400)
committerCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 22:13:07 +0000 (18:13 -0400)
I recently noticed a letter get "stuck" in the center area, (it appeared
to be there, but did not allow any interaction).

This commit is by claude which it described as follows, (note: in the
below, rAF is requestAnimationFrame):

* Fixed the likely culprit — rAF loop using stale state:

  The old code checked this.state.revealing[tile.id] synchronously
  after an async setState to decide whether to keep animating. Now it
  uses a local running flag, and the loop termination is decided
  inside the setState callback where it has access to the actual
  current state.

* Defensive cleanup in receive_center:

  When the server sends an authoritative center update, any revealing
  entries for tiles no longer in center are now cleaned up. This
  prevents ghost revealing state from accumulating.

* Periodic consistency check (every 5s):

  - Detects stale revealing entries for tiles not in center, logs a
    warning, and auto-cleans them

  - Detects inconsistent claim state

The combination of the rAF fix and the defensive cleanup in
receive_center should prevent the stuck-tile scenario. And if
something else causes drift in the future, the console warnings will
help diagnose it.

anagrams/anagrams.jsx

index 4749d7a07af4d0548034b33a7bad485ec0ae96d2..929f57dd4b10367c160044e9c9c03d8773ff3363 100644 (file)
@@ -142,10 +142,35 @@ class Game extends React.Component {
   componentDidMount() {
     this._onKeyDown = (e) => this.on_key_down(e);
     document.addEventListener("keydown", this._onKeyDown);
+
+    /* Periodic state consistency check (debug aid). */
+    this._debug_interval = setInterval(() => this._check_state(), 5000);
   }
 
   componentWillUnmount() {
     document.removeEventListener("keydown", this._onKeyDown);
+    if (this._debug_interval) clearInterval(this._debug_interval);
+  }
+
+  _check_state() {
+    const { center, revealing, claim_active, claim_player } = this.state;
+    const center_ids = new Set(center.map(t => t.id));
+
+    for (const id of Object.keys(revealing)) {
+      if (!center_ids.has(Number(id))) {
+        console.warn("[anagrams] Stale revealing entry for tile", id,
+                     "not in center — cleaning up");
+        this.setState(prev => {
+          const r = { ...prev.revealing };
+          delete r[id];
+          return { revealing: r };
+        });
+      }
+    }
+
+    if (claim_active && !claim_player) {
+      console.warn("[anagrams] claim_active but no claim_player");
+    }
   }
 
   /*****************************************************
@@ -183,7 +208,20 @@ class Game extends React.Component {
         new_count++;
       }
     }
-    this.setState({ center: tiles, tile_positions: positions }, () => {
+    /* Clean up revealing entries for tiles no longer in center. */
+    const tile_ids = new Set(tiles.map(t => t.id));
+    const revealing = { ...this.state.revealing };
+    let reveal_cleaned = false;
+    for (const id of Object.keys(revealing)) {
+      if (!tile_ids.has(Number(id))) {
+        delete revealing[id];
+        reveal_cleaned = true;
+      }
+    }
+    const update = { center: tiles, tile_positions: positions };
+    if (reveal_cleaned) update.revealing = revealing;
+
+    this.setState(update, () => {
       /* On bulk load (reconnection), arrange tiles in a grid. */
       if (new_count > 1) this.shuffle_tiles();
     });
@@ -213,29 +251,33 @@ class Game extends React.Component {
      * each fading from full opacity to transparent. */
     const phase_ms = countdown_ms / 3;
     const start = Date.now();
+    let running = true;
     const step = () => {
-      this.setState(prev => {
-        const r = { ...prev.revealing };
-        const entry = r[tile.id];
-        if (!entry) return { revealing: r };
+      if (!running) return;
 
-        const elapsed = Date.now() - start;
+      const elapsed = Date.now() - start;
 
-        if (elapsed >= countdown_ms) {
+      if (elapsed >= countdown_ms) {
+        running = false;
+        this.setState(prev => {
+          const r = { ...prev.revealing };
           delete r[tile.id];
           return { revealing: r };
-        }
+        });
+        return;
+      }
 
-        const phase = Math.min(2, Math.floor(elapsed / phase_ms));
-        const current_number = 3 - phase;
-        const frac = (elapsed - phase * phase_ms) / phase_ms;
+      const phase = Math.min(2, Math.floor(elapsed / phase_ms));
+      const current_number = 3 - phase;
+      const frac = (elapsed - phase * phase_ms) / phase_ms;
+      this.setState(prev => {
+        const r = { ...prev.revealing };
+        if (!r[tile.id]) { running = false; return null; }
         r[tile.id] = { number: current_number, opacity: 1 - frac };
         return { revealing: r };
       });
 
-      if (this.state.revealing[tile.id]) {
-        requestAnimationFrame(step);
-      }
+      requestAnimationFrame(step);
     };
     requestAnimationFrame(step);
   }