powergrid-viewer 2.0.7 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "powergrid-viewer",
3
- "version": "2.0.7",
3
+ "version": "2.0.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -727,6 +727,7 @@ import type { GameState, LogItem, Move, Player } from 'powergrid-engine';
727
727
  import { EventEmitter } from 'events';
728
728
  import {
729
729
  bufferedBuyCounts,
730
+ engineRefusedIt,
730
731
  lastBuyIndex,
731
732
  matchesTurnBuffer,
732
733
  rebaseTurnBuffer,
@@ -980,6 +981,9 @@ export default class Game extends Vue {
980
981
  // server-side from the last committed state; undo simply shortens it.
981
982
  turnMoves: Move[] = [];
982
983
 
984
+ /** Why the last local replay stopped, if it did — see `engineRefusedIt`. */
985
+ private lastReplayFailure: unknown;
986
+
983
987
  // Last committed state received from the platform. Undo replays the shortened
984
988
  // turn buffer from this state; when the buffer empties, the preview resets to it
985
989
  // without any server call (the platform's saved state IS the turn start).
@@ -1039,8 +1043,9 @@ export default class Game extends Vue {
1039
1043
  * engine now rejects — possible after a rebase) and returns the preview.
1040
1044
  */
1041
1045
  replayTurnBuffer(): GameState {
1042
- const { state, applied } = replayBuffer(this.committedState!, this.turnMoves, this.player!);
1046
+ const { state, applied, failure } = replayBuffer(this.committedState!, this.turnMoves, this.player!);
1043
1047
  this.turnMoves = applied;
1048
+ this.lastReplayFailure = failure;
1044
1049
  return state;
1045
1050
  }
1046
1051
 
@@ -1529,11 +1534,20 @@ export default class Game extends Vue {
1529
1534
  return;
1530
1535
  }
1531
1536
 
1537
+ // What the board offered when this click was judged — the same list the
1538
+ // button that produced it was enabled from.
1539
+ const offeredWhenClicked =
1540
+ this.player != undefined && this.G && this.G.players[this.player]
1541
+ ? this.G.players[this.player].availableMoves
1542
+ : undefined;
1543
+ const theServerOfferedThisMove = !!offeredWhenClicked && !!offeredWhenClicked[move.name];
1544
+
1532
1545
  // Stamp the move ONCE, when it enters the turn buffer, so the engine can
1533
1546
  // advance the per-player clocks. The engine never reads the system clock
1534
1547
  // itself — the stamp travels with the move on every resend of the buffer, so
1535
1548
  // replays (and the eventual committed log) reproduce the same times.
1536
- this.turnMoves.push({ ...move, time: Date.now() });
1549
+ const stamped = { ...move, time: Date.now() };
1550
+ this.turnMoves.push(stamped);
1537
1551
 
1538
1552
  // Preview the move locally BEFORE sending it. Without this the board — and with
1539
1553
  // it `availableMoves` — stays frozen on the last server reply, so every click
@@ -1542,9 +1556,6 @@ export default class Game extends Vue {
1542
1556
  // replayed buffer with "Wrong argument for the command BuyResource"; the
1543
1557
  // rejected move then stays in the buffer, so every later action resends it and
1544
1558
  // fails again until the page is refreshed (#131).
1545
- //
1546
- // Replaying the buffer on the committed state is exact: any move with hidden
1547
- // side effects commits, which ends the buffer (see util/turn-buffer).
1548
1559
  let preview: GameState | null = null;
1549
1560
 
1550
1561
  if (this.committedState && this.player != undefined) {
@@ -1552,9 +1563,31 @@ export default class Game extends Vue {
1552
1563
  preview = this.replayTurnBuffer();
1553
1564
 
1554
1565
  if (this.turnMoves.length < buffered) {
1555
- // The engine refused the move we just added, so it never reaches the
1556
- // server and the board already shows the truth.
1557
- return;
1566
+ // The replay could not carry the move which is NOT the same thing as
1567
+ // the move being illegal, and telling them apart is the whole job here.
1568
+ //
1569
+ // The state a player holds is stripped of everything they may not see.
1570
+ // A fastBid auction keeps the other players' sealed bids hidden, so the
1571
+ // response that RESOLVES that auction cannot be replayed on this copy at
1572
+ // all: the engine reaches for a bid that was stripped out and throws.
1573
+ // A human is almost always the last to answer — bots reply instantly —
1574
+ // so treating that as a refusal silently swallowed nearly every Pass in
1575
+ // a fastBid auction, with an enabled button and no way to tell.
1576
+ //
1577
+ // So defer to the only authority on legality that this viewer has: the
1578
+ // `availableMoves` the server sent. If it offered the move, send it and
1579
+ // let the server replay it from the full state; just do not pretend to
1580
+ // preview what could not be previewed.
1581
+ const onlyThisMoveWasDropped = this.turnMoves.length === buffered - 1;
1582
+
1583
+ if (engineRefusedIt(this.lastReplayFailure) || !theServerOfferedThisMove || !onlyThisMoveWasDropped) {
1584
+ // A genuinely stale click (#131): the engine asserted against it on
1585
+ // a state it could read in full. The board already shows the truth.
1586
+ return;
1587
+ }
1588
+
1589
+ this.turnMoves.push(stamped);
1590
+ preview = null;
1558
1591
  }
1559
1592
  }
1560
1593
 
@@ -131,21 +131,43 @@ export function replayTurnBuffer(
131
131
  committedState: GameState,
132
132
  turnMoves: Move[],
133
133
  player: number
134
- ): { state: GameState; applied: Move[] } {
134
+ ): { state: GameState; applied: Move[]; failure?: unknown } {
135
135
  let state: GameState = JSON.parse(JSON.stringify(committedState));
136
136
  const applied: Move[] = [];
137
+ let failure: unknown;
137
138
 
138
139
  for (const move of turnMoves) {
139
140
  try {
140
141
  state = engineMove(state, move, player);
141
142
  } catch (err) {
142
143
  console.error('dropping turn-buffer tail no longer legal on the committed state', move, err);
144
+ failure = err;
143
145
  break;
144
146
  }
145
147
  applied.push(move);
146
148
  }
147
149
 
148
- return { state, applied };
150
+ return { state, applied, failure };
151
+ }
152
+
153
+ /**
154
+ * Did the engine REFUSE this move, or could this copy of the state simply not carry it?
155
+ *
156
+ * The engine refuses with an assertion. A state stripped of what a player may not see —
157
+ * a fastBid auction's sealed bids — instead fails while reaching for something that was
158
+ * stripped out, which says nothing at all about legality.
159
+ */
160
+ export function engineRefusedIt(failure: unknown): boolean {
161
+ if (!failure) {
162
+ return false;
163
+ }
164
+
165
+ // Node stamps `code: 'ERR_ASSERTION'`; the browser build of `assert` does not, and
166
+ // checking only for the code let every illegal move through in the bundle while
167
+ // passing every test in Node.
168
+ const err = failure as { name?: string; code?: string };
169
+
170
+ return err.name === 'AssertionError' || err.code === 'ERR_ASSERTION';
149
171
  }
150
172
 
151
173
  /**