]> git.cworth.org Git - lmno.games/commitdiff
Allow queueing up a claim while another claim is in progress
authorCarl Worth <cworth@cworth.org>
Mon, 9 Mar 2026 15:36:20 +0000 (08:36 -0700)
committerCarl Worth <cworth@cworth.org>
Mon, 9 Mar 2026 15:36:20 +0000 (08:36 -0700)
Prior to this commit, when another player is claiming, the "Claim a
word" button would be deactivated. Now, instead, we allow this button
to be active, and it puts the player's claim request onto a queue so
that they will get their turn eventually.

anagrams/anagrams.jsx

index 9ff1c4b1c955e31757b8cef77320167567ee64de..0127c6c4e2ead36af4919050586963dfe1453282 100644 (file)
@@ -179,6 +179,7 @@ class Game extends React.Component {
       claiming: false,
       claim_active: false,      /* true if I'm the active claimer */
       claim_player: null,       /* name of the current claimer */
+      claim_queue_position: null, /* my position in queue, null if not queued */
       claim_rack: [],           /* { letter, source, tile?, word_id?, idx? } */
       claimed_words: [],        /* word objects I've stolen */
       claim_center_tiles: [],   /* center tiles I've taken */
@@ -368,43 +369,46 @@ class Game extends React.Component {
   receive_claim_start(data) {
     const my_session = this.state.player_info.id;
     const is_me = data.player_name === this.state.player_info.name;
-    this.setState({
+    this.setState(prev => ({
       claim_player: data.player_name,
       claim_active: is_me,
-      claiming: is_me,
-      claim_rack: is_me ? [] : this.state.claim_rack,
-      claimed_words: is_me ? [] : this.state.claimed_words,
-      claim_center_tiles: is_me ? [] : this.state.claim_center_tiles,
+      claiming: is_me || prev.claim_queue_position != null,
+      claim_queue_position: is_me ? null : prev.claim_queue_position,
+      claim_rack: is_me ? [] : prev.claim_rack,
+      claimed_words: is_me ? [] : prev.claimed_words,
+      claim_center_tiles: is_me ? [] : prev.claim_center_tiles,
       other_claim_letters: is_me ? [] : (data.claimed_letters || []),
       other_claim_words: is_me ? [] : (data.claimed_words || []),
       claim_error: null,
       claim_warning: false,
       claim_remaining_ms: data.timeout_ms
-    });
+    }));
 
     if (is_me && data.timeout_ms > 0) {
       this._start_claim_timer(data.timeout_ms, data.warning_ms);
     }
 
-    /* If a letter was queued from a keyboard-initiated claim, take it. */
-    if (is_me && this._pending_key) {
-      const pending = this._pending_key;
-      this._pending_key = null;
-      const tile = this.state.center.find(
-        t => t.letter === pending && !this.state.revealing[t.id]
-      );
-      if (tile) {
-        this.take_center_letter(tile);
-      }
+    if (is_me && this._pending_keys) {
+      this._replay_pending_keys();
     }
   }
 
   receive_claim_end(data) {
-    /* Center and player-words are resynced by separate server events. */
-    this.setState({
-      claim_player: null,
-      claim_active: false,
-      claiming: false,
+    /* Center and player-words are resynced by separate server events.
+     * If data.next is present, the next claimer is already active
+     * (merged into this event to avoid a gap). */
+    const next = data.next;
+    const next_is_me = next
+      && next.player_name === this.state.player_info.name;
+
+    this.setState(prev => ({
+      claim_player: next ? next.player_name : null,
+      claim_active: !!next_is_me,
+      claiming: next_is_me || prev.claim_queue_position > 1,
+      claim_queue_position: prev.claim_queue_position != null
+        ? (prev.claim_queue_position <= 1 ? null
+           : prev.claim_queue_position - 1)
+        : null,
       claim_rack: [],
       claimed_words: [],
       claim_center_tiles: [],
@@ -412,9 +416,23 @@ class Game extends React.Component {
       other_claim_words: [],
       claim_error: null,
       claim_warning: false,
-      claim_remaining_ms: 0
-    });
+      claim_remaining_ms: next_is_me ? next.timeout_ms : 0
+    }));
     this._stop_claim_timer();
+
+    if (next_is_me) {
+      this._start_claim_timer(next.timeout_ms, next.warning_ms);
+
+      /* We were queued, so the center may have changed. Check if
+       * all buffered letters are still available. If so, replay
+       * them all; if not, discard the buffer entirely rather than
+       * replaying a partial/garbled word. */
+      if (this._pending_keys) {
+        this._replay_pending_keys();
+      }
+    } else if (!next) {
+      this._pending_keys = null;
+    }
   }
 
   receive_letter_claimed(data) {
@@ -533,6 +551,24 @@ class Game extends React.Component {
     await fetch_post_json("still-looking");
   }
 
+  /* Replay buffered keystrokes from before claim was active.
+   * Each letter is taken if available, skipped if not. The user
+   * sees what they typed minus any missing letters and can work
+   * out what happened (typo, or letters taken by another player). */
+  _replay_pending_keys() {
+    const keys = this._pending_keys;
+    this._pending_keys = null;
+
+    for (const k of keys) {
+      const tile = this.state.center.find(
+        t => t.letter === k && !this.state.revealing[t.id]
+      );
+      if (tile) {
+        this.take_center_letter(tile);
+      }
+    }
+  }
+
   /*****************************************************
    * Actions                                           *
    *****************************************************/
@@ -553,7 +589,10 @@ class Game extends React.Component {
     const response = await fetch_post_json("claim");
     if (response.ok) {
       const data = await response.json();
-      this.setState({ claiming: data.queued });
+      this.setState({
+        claiming: data.queued,
+        claim_queue_position: data.active ? null : data.position
+      });
     }
   }
 
@@ -840,7 +879,7 @@ class Game extends React.Component {
         if (this.state.claim_rack.length >= 4) {
           this.submit_word();
         }
-      } else if (!this.state.claiming && !this.state.claim_player) {
+      } else if (!this.state.claiming) {
         this.start_claim();
       }
       return;
@@ -849,12 +888,19 @@ class Game extends React.Component {
     /* Everything below requires a letter key. */
     if (!/^[A-Z]$/.test(key)) return;
 
-    /* If not claiming yet, start a claim and queue the letter. */
+    /* If not claiming yet, start a claim and buffer typed letters.
+     * When the claim activates, all buffered letters are replayed.
+     * If we end up queued behind someone, only the first letter is
+     * kept (the center will likely change before our turn). */
     if (!this.state.claim_active) {
-      if (this.state.claiming || this.state.claim_player) return;
       e.preventDefault();
-      this._pending_key = key;
-      this.start_claim();
+      if (!this._pending_keys) {
+        if (this.state.claiming) return;
+        this._pending_keys = [key];
+        this.start_claim();
+      } else {
+        this._pending_keys.push(key);
+      }
       return;
     }
 
@@ -1132,18 +1178,31 @@ class Game extends React.Component {
 
   render_controls() {
     const state = this.state;
+    const is_queued = state.claim_queue_position != null;
+    const can_queue = state.claim_player && !state.claim_active
+                      && !is_queued && !state.game_over;
     const can_claim = !state.claiming && !state.claim_player
                       && !state.game_over;
-    const is_queued = state.claiming && !state.claim_active;
+
+    let button_label;
+    if (is_queued) {
+      const pos = state.claim_queue_position;
+      button_label = (pos <= 1 ? "You're next" : "You're #" + pos)
+        + " after " + state.claim_player;
+    } else if (can_queue) {
+      button_label = "Claim after " + state.claim_player;
+    } else if (state.claim_player && !state.claim_active) {
+      button_label = state.claim_player + " is claiming";
+    } else {
+      button_label = "Claim a Word";
+    }
 
     return (
       <div key="ctrl" className="controls">
         <button className={is_queued ? "queued" : ""}
-                disabled={!can_claim && !is_queued}
+                disabled={!can_claim && !can_queue}
                 onClick={() => this.start_claim()}>
-          {is_queued ? "Queued..." :
-           state.claim_player ? state.claim_player + " is claiming" :
-           "Claim a Word"}
+          {button_label}
         </button>
 
         <span className={"bag" + (state.bag_remaining === 0 || !!state.claim_player ? " disabled" : "")}