powergrid-viewer 2.0.6 → 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.6",
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
 
@@ -2024,6 +2057,10 @@ export default class Game extends Vue {
2024
2057
  * the choice would disappear along with the layout.
2025
2058
  */
2026
2059
  portraitViewport = false;
2060
+
2061
+ /** Viewport the board was last laid out against, as `WxH` — see onViewportResize. */
2062
+ private lastViewport = '';
2063
+ private viewportObserver: ResizeObserver | null = null;
2027
2064
  /** Height of the stacked canvas, in scene units. */
2028
2065
  stackHeight = STACK_WIDTH;
2029
2066
  /** Wrapper transform per slot; empty means "render as authored". */
@@ -2062,6 +2099,16 @@ export default class Game extends Vue {
2062
2099
  return window.innerWidth < 900 && window.innerHeight > window.innerWidth * 1.1;
2063
2100
  }
2064
2101
 
2102
+ /**
2103
+ * The platform mounts the viewer in an iframe that is `display: none` until the
2104
+ * game state arrives, so the first measurement here is 0x0 — which is not a
2105
+ * landscape phone, it is nothing at all. Answering then would settle the layout
2106
+ * against a viewport that does not exist yet.
2107
+ */
2108
+ private viewportIsMeasurable(): boolean {
2109
+ return window.innerWidth > 0 && window.innerHeight > 0;
2110
+ }
2111
+
2065
2112
  private shouldStack(): boolean {
2066
2113
  return this.isPortraitViewport() && this.preferences.stackOnPortrait !== false;
2067
2114
  }
@@ -2075,6 +2122,11 @@ export default class Game extends Vue {
2075
2122
  }
2076
2123
 
2077
2124
  relayout() {
2125
+ if (!this.viewportIsMeasurable()) {
2126
+ // Still hidden. Whoever reveals us triggers the observer below.
2127
+ return;
2128
+ }
2129
+
2078
2130
  this.portraitViewport = this.isPortraitViewport();
2079
2131
 
2080
2132
  if (!this.shouldStack()) {
@@ -2189,17 +2241,57 @@ export default class Game extends Vue {
2189
2241
  this.$nextTick(() => this.relayout());
2190
2242
  }
2191
2243
 
2192
- private onViewportResize = () => this.scheduleRelayout();
2244
+ /**
2245
+ * Re-lay out only when the viewport itself changed size. The observer below also
2246
+ * fires for our OWN output — laying out the board changes the document's box — and
2247
+ * without this that would be a loop.
2248
+ */
2249
+ private onViewportResize = () => {
2250
+ const size = `${window.innerWidth}x${window.innerHeight}`;
2251
+
2252
+ if (size === this.lastViewport) {
2253
+ return;
2254
+ }
2255
+
2256
+ this.lastViewport = size;
2257
+ this.scheduleRelayout();
2258
+ };
2193
2259
 
2194
2260
  mounted() {
2195
2261
  window.addEventListener('resize', this.onViewportResize);
2196
2262
  window.addEventListener('orientationchange', this.onViewportResize);
2263
+ window.addEventListener('load', this.onViewportResize);
2264
+
2265
+ if (window.visualViewport) {
2266
+ window.visualViewport.addEventListener('resize', this.onViewportResize);
2267
+ }
2268
+
2269
+ // The one signal that does not depend on the browser dispatching an event:
2270
+ // being revealed changes the document's own box. Safari does not reliably fire
2271
+ // `resize` inside an iframe going from `display: none` to visible, and the
2272
+ // board then stays in the landscape layout with no toggle to escape it — the
2273
+ // measurement was taken at 0x0 and nothing ever asked again.
2274
+ if (typeof ResizeObserver !== 'undefined') {
2275
+ this.viewportObserver = new ResizeObserver(this.onViewportResize);
2276
+ this.viewportObserver.observe(document.documentElement);
2277
+ }
2278
+
2197
2279
  this.scheduleRelayout();
2198
2280
  }
2199
2281
 
2200
2282
  beforeDestroy() {
2201
2283
  window.removeEventListener('resize', this.onViewportResize);
2202
2284
  window.removeEventListener('orientationchange', this.onViewportResize);
2285
+ window.removeEventListener('load', this.onViewportResize);
2286
+
2287
+ if (window.visualViewport) {
2288
+ window.visualViewport.removeEventListener('resize', this.onViewportResize);
2289
+ }
2290
+
2291
+ if (this.viewportObserver) {
2292
+ this.viewportObserver.disconnect();
2293
+ this.viewportObserver = null;
2294
+ }
2203
2295
  }
2204
2296
 
2205
2297
  // Boards grow as players buy plants, so the row heights are re-derived on
@@ -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
  /**