powergrid-viewer 2.0.4 → 2.0.5

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.4",
3
+ "version": "2.0.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -143,7 +143,23 @@
143
143
  </g>
144
144
 
145
145
  <g ref="slotResources" :transform="slotT('resources')">
146
+ <!-- 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. -->
150
+ <ResourceBoxes
151
+ v-if="stacked"
152
+ :transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
153
+ :gameState="G"
154
+ :player="player"
155
+ :isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
156
+ :buyableResources="buyableResources()"
157
+ :resourceResupply="getResourceResupply()"
158
+ :resourceResupplyNorth="getResourceResupplyNorth()"
159
+ @buyResource="buyResource($event)"
160
+ />
146
161
  <Resources
162
+ v-else
147
163
  ref="resources"
148
164
  :transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
149
165
  :isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
@@ -162,12 +178,76 @@
162
178
  />
163
179
  </g>
164
180
 
181
+ <!-- Australia: the same uranium-mine selling table laid on its side for
182
+ portrait. A 104x430 strip is the worst possible shape for a full-width
183
+ row — it can only grow until its HEIGHT fills the row, so it ends up a
184
+ sliver with the whole width beside it empty. Turned through 90° the six
185
+ prices run left to right in the same order they run top to bottom, and
186
+ the row is filled by something worth reading. -->
187
+ <g
188
+ v-if="stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
189
+ ref="slotUraniumMines"
190
+ :transform="slotT('uraniumMines') || 'translate(15, 70)'"
191
+ >
192
+ <rect x="0" y="0" width="620" height="146" rx="6" fill="#8aa84a" stroke="#4d6322" stroke-width="3" />
193
+ <text x="310" y="26" text-anchor="middle" font-weight="700" fill="black" style="font-size: 22px">
194
+ Uranium mine market
195
+ </text>
196
+ <g v-for="col in 6" :key="'uraniumCol' + col" :transform="`translate(${10 + (col - 1) * 100}, 0)`">
197
+ <text x="50" y="56" text-anchor="middle" font-weight="700" fill="black" style="font-size: 20px">
198
+ ${{ 8 - col }}
199
+ </text>
200
+ <rect
201
+ x="21"
202
+ y="66"
203
+ width="26"
204
+ height="26"
205
+ rx="3"
206
+ fill="goldenrod"
207
+ stroke="#4d6322"
208
+ stroke-width="1.5"
209
+ />
210
+ <rect
211
+ x="53"
212
+ y="66"
213
+ width="26"
214
+ height="26"
215
+ rx="3"
216
+ fill="goldenrod"
217
+ stroke="#4d6322"
218
+ stroke-width="1.5"
219
+ />
220
+ <circle
221
+ v-if="(G.uraniumMineMarket[6 - col] || 0) >= 1"
222
+ cx="34"
223
+ cy="79"
224
+ r="10"
225
+ fill="#46c655"
226
+ stroke="#1f5c25"
227
+ stroke-width="2"
228
+ />
229
+ <circle
230
+ v-if="(G.uraniumMineMarket[6 - col] || 0) >= 2"
231
+ cx="66"
232
+ cy="79"
233
+ r="10"
234
+ fill="#46c655"
235
+ stroke="#1f5c25"
236
+ stroke-width="2"
237
+ />
238
+ </g>
239
+ <line x1="10" y1="108" x2="610" y2="108" stroke="#4d6322" stroke-width="1" />
240
+ <text x="310" y="132" text-anchor="middle" font-weight="700" fill="#22340f" style="font-size: 18px">
241
+ refill −{{ G.map.uraniumMineResupply[G.players.length - 2][G.step - 1] }}/rnd
242
+ </text>
243
+ </g>
244
+
165
245
  <!-- Australia: uranium-mine selling table. Six price rows $7 (top) →
166
246
  $2 (bottom), two token slots each. Sellers place one token per mine
167
247
  on the highest empty slot; the resource refill removes from the
168
248
  cheap (bottom) end. Lives in the clear upper-left margin. -->
169
249
  <g
170
- v-if="G.map.name === 'Australia' && G.uraniumMineMarket"
250
+ v-if="!stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
171
251
  ref="slotUraniumMines"
172
252
  :transform="slotT('uraniumMines') || 'translate(15, 70)'"
173
253
  >
@@ -310,7 +390,7 @@
310
390
  :ended="gameEnded(G)"
311
391
  :isPlayer="player == playerIndex"
312
392
  :ranking="sortedPlayers.findIndex((x) => x.id == G.players[playerIndex].id) + 1"
313
- :showMoney="player == playerIndex || gameEnded(G) || G.options.showMoney"
393
+ :showMoney="player == playerIndex || moneyIsPublic"
314
394
  :showBid="!G.options.fastBid"
315
395
  :phase="G.phase"
316
396
  :isAustralia="G.map.name === 'Australia'"
@@ -660,6 +740,7 @@ import PowerPlantMarket from './boards/PowerPlantMarket.vue';
660
740
  import PlayerOrder from './boards/PlayerOrder.vue';
661
741
  import CityCount from './boards/CityCount.vue';
662
742
  import Map from './boards/Map.vue';
743
+ import ResourceBoxes from './boards/ResourceBoxes.vue';
663
744
  import Resources from './boards/Resources.vue';
664
745
  import { LogMove } from 'powergrid-engine/src/log';
665
746
  import { Phase, playerTimeUsed, PowerPlant, PowerPlantType, ResourceType } from 'powergrid-engine/src/gamestate';
@@ -719,6 +800,9 @@ const STACK_SLOT_MAX_SCALE: Record<string, number> = {
719
800
  */
720
801
  const STACK_SLOT_MAX_WIDTH: Record<string, number> = {
721
802
  powerPlantMarket: 0.88,
803
+ // The box market is one tall stack of full-width buttons; run edge to edge it
804
+ // reads as though it had been cropped rather than laid out.
805
+ resources: 0.94,
722
806
  };
723
807
 
724
808
  /**
@@ -794,7 +878,8 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
794
878
  PlayerOrder,
795
879
  CityCount,
796
880
  Map,
797
- Resources
881
+ Resources,
882
+ ResourceBoxes
798
883
  },
799
884
  })
800
885
  export default class Game extends Vue {
@@ -822,6 +907,11 @@ export default class Game extends Vue {
822
907
 
823
908
  paused = false;
824
909
 
910
+ // Set once the platform has served a finished game, and never cleared.
911
+ // Reactive on purpose: `_futureState` is not, and the money gate has to
912
+ // re-evaluate as the replay scrubber steps through the game.
913
+ gameIsOver = false;
914
+
825
915
  @Provide()
826
916
  communicator: EventEmitter = new EventEmitter();
827
917
 
@@ -944,11 +1034,19 @@ export default class Game extends Vue {
944
1034
  return state;
945
1035
  }
946
1036
 
947
- replaceState(state: GameState, replaceState = true) {
1037
+ replaceState(state: GameState, replaceState = true, playSound = true) {
948
1038
  if (replaceState) {
949
1039
  this._futureState = state;
950
1040
  }
951
1041
 
1042
+ // Remember that the platform has served us a finished game. The replay
1043
+ // scrubber renders reconstructed MID-game states, whose own `ended()` is
1044
+ // false however long ago the game finished, so it cannot be asked. See
1045
+ // `moneyIsPublic`.
1046
+ if (replaceState && ended(state)) {
1047
+ this.gameIsOver = true;
1048
+ }
1049
+
952
1050
  // if player is selecting resources, keep the state
953
1051
  let player: any;
954
1052
  if (this.player != null) {
@@ -974,7 +1072,8 @@ export default class Game extends Vue {
974
1072
  this.playerOrder.createPieces(this.G!);
975
1073
  this.cityCount.createPieces(this.G!);
976
1074
  this.map.createPieces(this.G!);
977
- this.resources.createPieces(this.G!);
1075
+ // Absent while the portrait layout is showing the box market instead.
1076
+ this.resources?.createPieces(this.G!);
978
1077
  // Pieces are added imperatively, so the groups only reach their
979
1078
  // final size here — the portrait layout must measure after this,
980
1079
  // not on the state change that triggered it.
@@ -982,7 +1081,7 @@ export default class Game extends Vue {
982
1081
  });
983
1082
  }
984
1083
 
985
- if (this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
1084
+ if (playSound && this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
986
1085
  const move = (this.G?.log[this.G?.log.length - 1] as LogMove).move;
987
1086
  if (move.name == MoveName.Pass && this.G.currentPlayers.includes(this.player!)) {
988
1087
  (document.getElementById('notification')!.cloneNode(true) as HTMLAudioElement).play();
@@ -1385,15 +1484,69 @@ export default class Game extends Vue {
1385
1484
  // replays (and the eventual committed log) reproduce the same times.
1386
1485
  this.turnMoves.push({ ...move, time: Date.now() });
1387
1486
 
1487
+ // Preview the move locally BEFORE sending it. Without this the board — and with
1488
+ // it `availableMoves` — stays frozen on the last server reply, so every click
1489
+ // made while a response is in flight is judged against a stale list. Buy one
1490
+ // resource past what your plants can hold and the platform rejects the whole
1491
+ // replayed buffer with "Wrong argument for the command BuyResource"; the
1492
+ // rejected move then stays in the buffer, so every later action resends it and
1493
+ // fails again until the page is refreshed (#131).
1494
+ //
1495
+ // Replaying the buffer on the committed state is exact: any move with hidden
1496
+ // side effects commits, which ends the buffer (see util/turn-buffer).
1497
+ let preview: GameState | null = null;
1498
+
1499
+ if (this.committedState && this.player != undefined) {
1500
+ const buffered = this.turnMoves.length;
1501
+ preview = this.replayTurnBuffer();
1502
+
1503
+ if (this.turnMoves.length < buffered) {
1504
+ // The engine refused the move we just added, so it never reaches the
1505
+ // server and the board already shows the truth.
1506
+ return;
1507
+ }
1508
+ }
1509
+
1388
1510
  // Send the WHOLE turn so far: the platform is stateless between calls and
1389
1511
  // replays the buffer from the last committed (saved) state.
1390
1512
  this.emitter.emit('move', [...this.turnMoves]);
1513
+
1514
+ // Only adopt a preview that is still TENTATIVE. A committing move (Pass, a bid
1515
+ // that ends an auction) replays hidden outcomes — deck draws, upkeep — on a
1516
+ // state whose deck and seed are stripped, so its preview would flash bogus
1517
+ // results; wait for the server's committed answer instead. The echo of this
1518
+ // same buffer lands next and is authoritative, and it owns the sound, so the
1519
+ // preview stays silent rather than doubling it.
1520
+ if (preview && preview.newTurn === false) {
1521
+ this.replaceState(preview, false, false);
1522
+ }
1391
1523
  }
1392
1524
 
1393
1525
  gameEnded(G: GameState) {
1394
1526
  return ended(G);
1395
1527
  }
1396
1528
 
1529
+ /**
1530
+ * Is every player's cash public?
1531
+ *
1532
+ * Money is hidden information while a game is running, but a finished game has
1533
+ * nothing left to hide — the engine's `stripSecret` reveals it and the end-game
1534
+ * table prints it. The replay scrubber, though, shows a RECONSTRUCTED mid-game
1535
+ * state, and `ended()` on that state is false however long ago the game actually
1536
+ * finished. So stepping back through a finished game blacked out everyone
1537
+ * else's cash, which is most of what makes a replay worth reviewing: you could
1538
+ * not tell whether someone ending the game early would have changed the result
1539
+ * (eric-hu, #130). Ask whether the GAME is over, not whether the step being
1540
+ * displayed is.
1541
+ *
1542
+ * An in-progress game stays hidden, which is the point — the reconstruction
1543
+ * derives every player's true cash from the log, so this gate is the only thing
1544
+ * standing between a mid-game replay and other people's wallets.
1545
+ */
1546
+ get moneyIsPublic(): boolean {
1547
+ return !!this.G && (!!this.G.options.showMoney || this.gameIsOver || ended(this.G));
1548
+ }
1549
+
1397
1550
  canMove() {
1398
1551
  return (
1399
1552
  this.player != undefined &&
@@ -1968,6 +2121,19 @@ export default class Game extends Vue {
1968
2121
  this.stacked = true;
1969
2122
  }
1970
2123
 
2124
+ /**
2125
+ * Entering or leaving the stacked layout swaps the printed resource track for the
2126
+ * box market, which is a different shape entirely. `relayout` sets `stacked` last,
2127
+ * so the swap only reaches the DOM a tick later — by which point the row heights
2128
+ * it just solved for describe the component that is no longer there. Measure again
2129
+ * once the swap has landed. This settles after one extra pass: the second run
2130
+ * leaves `stacked` unchanged, so it does not re-trigger.
2131
+ */
2132
+ @Watch('stacked')
2133
+ onStackedChanged() {
2134
+ this.scheduleRelayout();
2135
+ }
2136
+
1971
2137
  private scheduleRelayout() {
1972
2138
  this.$nextTick(() => this.relayout());
1973
2139
  }
@@ -0,0 +1,212 @@
1
+ <template>
2
+ <g>
3
+ <template v-for="(block, b) in blocks">
4
+ <g :key="'block' + b" :transform="`translate(0, ${block.y})`">
5
+ <text v-if="block.title" x="4" y="16" font-weight="700" fill="black" style="font-size: 26px">
6
+ {{ block.title }}
7
+ </text>
8
+
9
+ <!-- Restock, as the printed refill card reads it: one number per row,
10
+ in the same order the rows are drawn. -->
11
+ <text
12
+ v-if="block.restock"
13
+ :x="BOX_W - 4"
14
+ y="16"
15
+ text-anchor="end"
16
+ font-weight="600"
17
+ fill="#3a2c08"
18
+ style="font-size: 24px"
19
+ >
20
+ Restock {{ block.restock }}
21
+ </text>
22
+
23
+ <text x="4" y="42" fill="#3a2c08" style="font-size: 24px; letter-spacing: 1px">CURRENT PRICE</text>
24
+ <text
25
+ :x="CUBE_BAND_CENTRE"
26
+ y="42"
27
+ text-anchor="middle"
28
+ fill="#3a2c08"
29
+ style="font-size: 24px; letter-spacing: 1px"
30
+ >
31
+ AT THIS PRICE
32
+ </text>
33
+ <text
34
+ :x="BOX_W - 4"
35
+ y="42"
36
+ text-anchor="end"
37
+ fill="#3a2c08"
38
+ style="font-size: 24px; letter-spacing: 1px"
39
+ >
40
+ CUBES LEFT
41
+ </text>
42
+
43
+ <g
44
+ v-for="(row, r) in block.rows"
45
+ :key="'row' + r"
46
+ :transform="`translate(0, ${HEAD_H + r * (BOX_H + BOX_GAP)})`"
47
+ :class="[{ canClick: row.buyable }]"
48
+ @click="row.buyable && $emit('buyResource', row.move)"
49
+ >
50
+ <rect
51
+ :width="BOX_W"
52
+ :height="BOX_H"
53
+ rx="6"
54
+ :fill="row.cubes > 0 ? 'darkgoldenrod' : '#9c8236'"
55
+ :stroke="row.buyable ? '#1e63d0' : '#6b5312'"
56
+ :stroke-width="row.buyable ? 5 : 2"
57
+ :opacity="row.cubes > 0 ? 1 : 0.55"
58
+ />
59
+ <!-- Whole box is the tap target, cubes and all. -->
60
+ <rect
61
+ v-if="row.buyable"
62
+ :width="BOX_W"
63
+ :height="BOX_H"
64
+ rx="6"
65
+ fill="transparent"
66
+ pointer-events="all"
67
+ />
68
+
69
+ <text x="18" y="26" font-weight="700" fill="black" style="font-size: 22px; letter-spacing: 1px">
70
+ {{ row.label }}
71
+ </text>
72
+
73
+ <template v-if="row.price != null">
74
+ <text x="18" y="80" font-weight="700" fill="black" style="font-size: 26px">$</text>
75
+ <text x="40" y="84" font-weight="700" fill="black" style="font-size: 46px">
76
+ {{ row.price }}
77
+ </text>
78
+ <text v-if="row.next != null" x="132" y="80" fill="#3a2c08" style="font-size: 22px">
79
+ then ${{ row.next }}
80
+ </text>
81
+ </template>
82
+ <text v-else x="18" y="78" font-weight="700" fill="black" style="font-size: 28px">out</text>
83
+
84
+ <!-- One dot per cube still buyable at the current price. A pool
85
+ with no price track behind it has no such number: every cube
86
+ in it costs the same, so say that instead of drawing dots. -->
87
+ <circle
88
+ v-for="n in row.atPrice"
89
+ :key="'cube' + n"
90
+ :cx="cubeX(row.atPrice, n)"
91
+ :cy="58"
92
+ r="17"
93
+ :fill="row.color"
94
+ stroke="black"
95
+ stroke-width="2"
96
+ />
97
+ <text v-if="row.flat && row.cubes > 0" x="300" y="66" fill="#3a2c08" style="font-size: 22px">
98
+ every cube at this price
99
+ </text>
100
+
101
+ <text
102
+ :x="BOX_W - 18"
103
+ y="68"
104
+ text-anchor="end"
105
+ font-weight="700"
106
+ fill="black"
107
+ style="font-size: 40px"
108
+ >
109
+ {{ row.cubes }}
110
+ </text>
111
+ <text :x="BOX_W - 18" y="90" text-anchor="end" fill="#3a2c08" style="font-size: 20px">LEFT</text>
112
+
113
+ <!-- India caps which prices are on sale per step; a row above the
114
+ cap is on the board but cannot be bought by anyone yet. -->
115
+ <text v-if="row.capped" x="130" y="96" fill="#7a1d12" font-weight="600" style="font-size: 20px">
116
+ step {{ step }} sells to ${{ maxPrice }}
117
+ </text>
118
+
119
+ <title>{{ row.title }}</title>
120
+ </g>
121
+ </g>
122
+ </template>
123
+ </g>
124
+ </template>
125
+
126
+ <script lang="ts">
127
+ import { GameState } from 'powergrid-engine';
128
+ import { Component, Prop, Vue } from 'vue-property-decorator';
129
+ import { BuyMove, ResourceBlock, resourceBlocks } from '../../util/resource-rows';
130
+
131
+ const BOX_W = 700;
132
+ const BOX_H = 104;
133
+ const BOX_GAP = 8;
134
+ /** Block title + column captions above the first box. */
135
+ const HEAD_H = 62;
136
+ const BLOCK_GAP = 30;
137
+ const CUBE_PITCH = 44;
138
+ /** Middle of the band between the price block and the cubes-left count. */
139
+ const CUBE_BAND_CENTRE = 420;
140
+
141
+ /**
142
+ * The portrait resource market.
143
+ *
144
+ * The printed price track is a beautiful object and an unreadable one on a phone:
145
+ * eight to ten columns of three-cube slots, drawn at whatever width the map author
146
+ * chose and then squeezed into a strip. This draws the same market as one box per
147
+ * buyable source, carrying only what a purchase actually needs — what the next cube
148
+ * costs, how many are left at that price, how many are left at all.
149
+ *
150
+ * Every number comes out of the engine state rather than the printed geometry, so the
151
+ * map-specific markets come along for free: Europe's nine price columns, Korea's two
152
+ * sides, Australia's Step-3 CO2 shift (the engine rewrites the price array and the box
153
+ * is only ever a view of it), India's per-step price cap, South Africa's $8 storage
154
+ * pool, USA recharged's $8-from-supply once the track is bare.
155
+ *
156
+ * Which rows a player may actually tap is NOT decided here: `buyableResources` is the
157
+ * engine's own availableMoves, so money, storage capacity, the hybrid tank and Korea's
158
+ * side lock are all accounted for already. The row model itself lives in
159
+ * `util/resource-rows`, where it can be tested against the engine's own arithmetic.
160
+ */
161
+ @Component
162
+ export default class ResourceBoxes extends Vue {
163
+ @Prop() gameState!: GameState;
164
+ @Prop() buyableResources?: BuyMove[];
165
+ @Prop() resourceResupply?: (number | string)[];
166
+ @Prop() resourceResupplyNorth?: (number | string)[];
167
+ @Prop() isUsaRecharged?: boolean;
168
+ /** The seat looking at the board — only needed for Central Europe's Wien discount. */
169
+ @Prop() player?: number;
170
+
171
+ BOX_W = BOX_W;
172
+ BOX_H = BOX_H;
173
+ BOX_GAP = BOX_GAP;
174
+ HEAD_H = HEAD_H;
175
+ CUBE_BAND_CENTRE = CUBE_BAND_CENTRE;
176
+
177
+ /** Centre the cube cluster in that band, however many cubes there are. */
178
+ cubeX(count: number, n: number): number {
179
+ return CUBE_BAND_CENTRE - ((count - 1) * CUBE_PITCH) / 2 + (n - 1) * CUBE_PITCH;
180
+ }
181
+
182
+ get step(): number {
183
+ return this.gameState.step;
184
+ }
185
+
186
+ get maxPrice(): number {
187
+ const table = (this.gameState.map as { maxPriceAvailable?: number[] }).maxPriceAvailable;
188
+ return table ? table[this.gameState.step - 1] : 16;
189
+ }
190
+
191
+ get blocks(): (ResourceBlock & { y: number })[] {
192
+ let y = 0;
193
+ return resourceBlocks(this.gameState, {
194
+ player: this.player,
195
+ buyable: this.buyableResources,
196
+ resupply: this.resourceResupply,
197
+ resupplyNorth: this.resourceResupplyNorth,
198
+ isUsaRecharged: this.isUsaRecharged,
199
+ }).map((block) => {
200
+ const placed = { ...block, y };
201
+ y += HEAD_H + block.rows.length * (BOX_H + BOX_GAP) - BOX_GAP + BLOCK_GAP;
202
+ return placed;
203
+ });
204
+ }
205
+ }
206
+ </script>
207
+
208
+ <style lang="scss" scoped>
209
+ .canClick {
210
+ cursor: pointer;
211
+ }
212
+ </style>
package/src/launch.ts CHANGED
@@ -15,14 +15,18 @@ function launch(selector: string) {
15
15
  } = {
16
16
  state: null,
17
17
  emitter: new EventEmitter(),
18
- preferences: {
18
+ // Observable so preference changes update the UI immediately: Game receives
19
+ // this object as a prop, and Vue 2 does not deep-observe prop values coming
20
+ // from a non-reactive parent — plain-object mutations (the in-game sound/help
21
+ // toggles, platform preference pushes) would only paint on the next re-render.
22
+ preferences: Vue.observable({
19
23
  sound: true,
20
24
  disableHelp: false,
21
25
  adjustPlayerOrder: false,
22
26
  undoWholeTurn: true,
23
27
  fitToScreen: true,
24
28
  stackOnPortrait: true,
25
- },
29
+ }),
26
30
  avatars: [],
27
31
  };
28
32
 
@@ -61,7 +65,8 @@ function launch(selector: string) {
61
65
  app.$forceUpdate();
62
66
  });
63
67
  item.addListener('preferences', (data) => {
64
- params.preferences = { ...params.preferences, ...data };
68
+ // Mutate (don't replace) the observable object so the update stays reactive
69
+ Object.assign(params.preferences, data);
65
70
  app.$forceUpdate();
66
71
  });
67
72
  item.addListener('gamelog', (logData) => {