powergrid-viewer 2.0.7 → 2.0.9

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.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -142,13 +142,21 @@
142
142
  </template>
143
143
  </g>
144
144
 
145
+ <g v-if="stacked" ref="slotResourceView" :transform="slotT('resourceView')">
146
+ <ResourceViewButton :showTrack="showResourceTrack" @select="setResourceView($event)" />
147
+ </g>
148
+
145
149
  <g ref="slotResources" :transform="slotT('resources')">
146
150
  <!-- On a phone the printed price track is a strip of unreadable
147
- columns, so the stacked layout swaps it for one box per buyable
148
- source. Landscape and desktop keep the printed board exactly as
149
- it was this is an either/or, never an overlay. -->
151
+ columns, so the stacked layout defaults to one box per buyable
152
+ source. It is still only a default: the switch above this row
153
+ puts the printed track back, because the boxes deliberately
154
+ throw away the price ladder and that ladder is most of what
155
+ there is to know about uranium. Landscape and desktop keep the
156
+ printed board exactly as it was — an either/or, never an
157
+ overlay. -->
150
158
  <ResourceBoxes
151
- v-if="stacked"
159
+ v-if="stacked && !showResourceTrack"
152
160
  :transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
153
161
  :gameState="G"
154
162
  :player="player"
@@ -727,6 +735,7 @@ import type { GameState, LogItem, Move, Player } from 'powergrid-engine';
727
735
  import { EventEmitter } from 'events';
728
736
  import {
729
737
  bufferedBuyCounts,
738
+ engineRefusedIt,
730
739
  lastBuyIndex,
731
740
  matchesTurnBuffer,
732
741
  rebaseTurnBuffer,
@@ -743,6 +752,7 @@ import {
743
752
  HelpButton,
744
753
  RulesButton,
745
754
  LayoutButton,
755
+ ResourceViewButton,
746
756
  } from './buttons';
747
757
  import PlayerBoard from './PlayerBoard.vue';
748
758
  import Calculator from './Calculator.vue';
@@ -770,6 +780,9 @@ const STACK_ROWS: string[][] = [
770
780
  // the draw pile and the round readout are things you glance at.
771
781
  ['buttons', 'roundInfo', 'powerPlantDeck'],
772
782
  ['powerPlantMarket'],
783
+ // The resource-display switch sits between the two markets rather than in the
784
+ // icon column: it belongs to the row it changes, and reads as that row's header.
785
+ ['resourceView'],
773
786
  ['resources'],
774
787
  ['uraniumMines'],
775
788
  ['freeJump'],
@@ -813,6 +826,9 @@ const STACK_SLOT_MAX_WIDTH: Record<string, number> = {
813
826
  // The box market is one tall stack of full-width buttons; run edge to edge it
814
827
  // reads as though it had been cropped rather than laid out.
815
828
  resources: 0.94,
829
+ // Matched to `resources` on purpose: equal budgets render equal widths, so the
830
+ // switch and the market below it line up instead of nearly lining up.
831
+ resourceView: 0.94,
816
832
  };
817
833
 
818
834
  /**
@@ -882,6 +898,7 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
882
898
  HelpButton,
883
899
  RulesButton,
884
900
  LayoutButton,
901
+ ResourceViewButton,
885
902
  Button,
886
903
  Calculator,
887
904
  PowerPlantMarket,
@@ -980,6 +997,9 @@ export default class Game extends Vue {
980
997
  // server-side from the last committed state; undo simply shortens it.
981
998
  turnMoves: Move[] = [];
982
999
 
1000
+ /** Why the last local replay stopped, if it did — see `engineRefusedIt`. */
1001
+ private lastReplayFailure: unknown;
1002
+
983
1003
  // Last committed state received from the platform. Undo replays the shortened
984
1004
  // turn buffer from this state; when the buffer empties, the preview resets to it
985
1005
  // without any server call (the platform's saved state IS the turn start).
@@ -1039,8 +1059,9 @@ export default class Game extends Vue {
1039
1059
  * engine now rejects — possible after a rebase) and returns the preview.
1040
1060
  */
1041
1061
  replayTurnBuffer(): GameState {
1042
- const { state, applied } = replayBuffer(this.committedState!, this.turnMoves, this.player!);
1062
+ const { state, applied, failure } = replayBuffer(this.committedState!, this.turnMoves, this.player!);
1043
1063
  this.turnMoves = applied;
1064
+ this.lastReplayFailure = failure;
1044
1065
  return state;
1045
1066
  }
1046
1067
 
@@ -1529,11 +1550,20 @@ export default class Game extends Vue {
1529
1550
  return;
1530
1551
  }
1531
1552
 
1553
+ // What the board offered when this click was judged — the same list the
1554
+ // button that produced it was enabled from.
1555
+ const offeredWhenClicked =
1556
+ this.player != undefined && this.G && this.G.players[this.player]
1557
+ ? this.G.players[this.player].availableMoves
1558
+ : undefined;
1559
+ const theServerOfferedThisMove = !!offeredWhenClicked && !!offeredWhenClicked[move.name];
1560
+
1532
1561
  // Stamp the move ONCE, when it enters the turn buffer, so the engine can
1533
1562
  // advance the per-player clocks. The engine never reads the system clock
1534
1563
  // itself — the stamp travels with the move on every resend of the buffer, so
1535
1564
  // replays (and the eventual committed log) reproduce the same times.
1536
- this.turnMoves.push({ ...move, time: Date.now() });
1565
+ const stamped = { ...move, time: Date.now() };
1566
+ this.turnMoves.push(stamped);
1537
1567
 
1538
1568
  // Preview the move locally BEFORE sending it. Without this the board — and with
1539
1569
  // it `availableMoves` — stays frozen on the last server reply, so every click
@@ -1542,9 +1572,6 @@ export default class Game extends Vue {
1542
1572
  // replayed buffer with "Wrong argument for the command BuyResource"; the
1543
1573
  // rejected move then stays in the buffer, so every later action resends it and
1544
1574
  // 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
1575
  let preview: GameState | null = null;
1549
1576
 
1550
1577
  if (this.committedState && this.player != undefined) {
@@ -1552,9 +1579,31 @@ export default class Game extends Vue {
1552
1579
  preview = this.replayTurnBuffer();
1553
1580
 
1554
1581
  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;
1582
+ // The replay could not carry the move which is NOT the same thing as
1583
+ // the move being illegal, and telling them apart is the whole job here.
1584
+ //
1585
+ // The state a player holds is stripped of everything they may not see.
1586
+ // A fastBid auction keeps the other players' sealed bids hidden, so the
1587
+ // response that RESOLVES that auction cannot be replayed on this copy at
1588
+ // all: the engine reaches for a bid that was stripped out and throws.
1589
+ // A human is almost always the last to answer — bots reply instantly —
1590
+ // so treating that as a refusal silently swallowed nearly every Pass in
1591
+ // a fastBid auction, with an enabled button and no way to tell.
1592
+ //
1593
+ // So defer to the only authority on legality that this viewer has: the
1594
+ // `availableMoves` the server sent. If it offered the move, send it and
1595
+ // let the server replay it from the full state; just do not pretend to
1596
+ // preview what could not be previewed.
1597
+ const onlyThisMoveWasDropped = this.turnMoves.length === buffered - 1;
1598
+
1599
+ if (engineRefusedIt(this.lastReplayFailure) || !theServerOfferedThisMove || !onlyThisMoveWasDropped) {
1600
+ // A genuinely stale click (#131): the engine asserted against it on
1601
+ // a state it could read in full. The board already shows the truth.
1602
+ return;
1603
+ }
1604
+
1605
+ this.turnMoves.push(stamped);
1606
+ preview = null;
1558
1607
  }
1559
1608
  }
1560
1609
 
@@ -2088,6 +2137,43 @@ export default class Game extends Vue {
2088
2137
  this.relayout();
2089
2138
  }
2090
2139
 
2140
+ /**
2141
+ * Only ever consulted inside the stacked layout — landscape and desktop draw the
2142
+ * printed board regardless, so this preference cannot reach them.
2143
+ */
2144
+ get showResourceTrack(): boolean {
2145
+ return this.preferences.portraitResourceTrack === true;
2146
+ }
2147
+
2148
+ setResourceView(showTrack: boolean) {
2149
+ if (showTrack === this.showResourceTrack) {
2150
+ return;
2151
+ }
2152
+
2153
+ this.emitter.emit('update:preference', { name: 'portraitResourceTrack', value: showTrack });
2154
+ this.preferences.portraitResourceTrack = showTrack;
2155
+ }
2156
+
2157
+ /**
2158
+ * The two resource displays are different shapes, so switching between them
2159
+ * invalidates the row heights the last layout solved for — the same trap as
2160
+ * entering the stacked layout at all. Measure again once the swap has landed.
2161
+ */
2162
+ @Watch('showResourceTrack')
2163
+ onResourceViewChanged() {
2164
+ this.$nextTick(() => {
2165
+ // The printed track fills itself imperatively and `createPieces` only ever
2166
+ // runs on a state update, so a track switched on mid-turn would draw an
2167
+ // EMPTY market until the next move landed — a display that lies. Fill it
2168
+ // before measuring: the cubes are what give the group its size.
2169
+ if (this.G) {
2170
+ this.resources?.createPieces(this.G);
2171
+ }
2172
+
2173
+ this.scheduleRelayout();
2174
+ });
2175
+ }
2176
+
2091
2177
  relayout() {
2092
2178
  if (!this.viewportIsMeasurable()) {
2093
2179
  // Still hidden. Whoever reveals us triggers the observer below.
@@ -0,0 +1,68 @@
1
+ <template>
2
+ <!--
3
+ Which resource display the portrait layout shows. A labelled either/or rather
4
+ than an icon: this changes what the whole row below it *is*, and no picture of
5
+ "boxes" or "a track" is recognisable before you tap it.
6
+
7
+ Authored 620x72 so that, held to the same 0.94 width budget as the box market
8
+ underneath, the two line up edge to edge and each half clears the 44px tap
9
+ guideline on a 414px-wide phone.
10
+ -->
11
+ <g class="resourceView">
12
+ <g class="button enabled" @click="$emit('select', false)">
13
+ <rect
14
+ x="0"
15
+ y="0"
16
+ width="306"
17
+ height="72"
18
+ rx="6"
19
+ :fill="showTrack ? 'gainsboro' : '#333'"
20
+ stroke="black"
21
+ stroke-width="3"
22
+ />
23
+ <text
24
+ x="153"
25
+ y="47"
26
+ text-anchor="middle"
27
+ font-weight="700"
28
+ :fill="showTrack ? 'black' : 'white'"
29
+ style="font-size: 30px"
30
+ >
31
+ Buy boxes
32
+ </text>
33
+ <title>One box per buyable source: the price you pay, and the whole box is the tap target</title>
34
+ </g>
35
+ <g class="button enabled" @click="$emit('select', true)">
36
+ <rect
37
+ x="314"
38
+ y="0"
39
+ width="306"
40
+ height="72"
41
+ rx="6"
42
+ :fill="showTrack ? '#333' : 'gainsboro'"
43
+ stroke="black"
44
+ stroke-width="3"
45
+ />
46
+ <text
47
+ x="467"
48
+ y="47"
49
+ text-anchor="middle"
50
+ font-weight="700"
51
+ :fill="showTrack ? 'white' : 'black'"
52
+ style="font-size: 30px"
53
+ >
54
+ Price track
55
+ </text>
56
+ <title>The printed market board, so the whole price track is visible at once</title>
57
+ </g>
58
+ </g>
59
+ </template>
60
+ <script lang="ts">
61
+ import { Vue, Component, Prop } from 'vue-property-decorator';
62
+
63
+ @Component
64
+ export default class ResourceViewButton extends Vue {
65
+ @Prop()
66
+ showTrack?: boolean;
67
+ }
68
+ </script>
@@ -4,7 +4,18 @@ import LayoutButton from './LayoutButton.vue';
4
4
  import LogButton from './LogButton.vue';
5
5
  import PassButton from './PassButton.vue';
6
6
  import SoundButton from './SoundButton.vue';
7
+ import ResourceViewButton from './ResourceViewButton.vue';
7
8
  import RulesButton from './RulesButton.vue';
8
9
  import UndoButton from './UndoButton.vue';
9
10
 
10
- export { Button, HelpButton, LayoutButton, LogButton, PassButton, SoundButton, RulesButton, UndoButton };
11
+ export {
12
+ Button,
13
+ HelpButton,
14
+ LayoutButton,
15
+ LogButton,
16
+ PassButton,
17
+ ResourceViewButton,
18
+ SoundButton,
19
+ RulesButton,
20
+ UndoButton,
21
+ };
package/src/launch.ts CHANGED
@@ -26,6 +26,7 @@ function launch(selector: string) {
26
26
  undoWholeTurn: true,
27
27
  fitToScreen: true,
28
28
  stackOnPortrait: true,
29
+ portraitResourceTrack: false,
29
30
  }),
30
31
  avatars: [],
31
32
  };
@@ -13,6 +13,11 @@ export type Preferences = {
13
13
  // Portrait phones stack the board into full-width rows. Per player, not per
14
14
  // game: it describes the screen you are holding, not the rules being played.
15
15
  stackOnPortrait: boolean;
16
+ // Within the stacked layout, show the printed price track instead of the box
17
+ // market. The boxes are the buying surface; the track is the reading surface --
18
+ // it is the only place the whole price ladder is visible at once, which matters
19
+ // most for uranium, where the cubes are few and the prices far apart.
20
+ portraitResourceTrack: boolean;
16
21
  };
17
22
 
18
23
  export interface Piece {
@@ -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
  /**