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
package/src/components/Game.vue
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1556
|
-
//
|
|
1557
|
-
|
|
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
|
-
|
|
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
|
package/src/util/turn-buffer.ts
CHANGED
|
@@ -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
|
/**
|