powergrid-viewer 2.0.4 → 2.0.6

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.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -18,7 +18,7 @@
18
18
  "vue": "^2.6.11",
19
19
  "vue-class-component": "^7.2.3",
20
20
  "vue-property-decorator": "^8.4.2",
21
- "powergrid-engine": "2.0.3"
21
+ "powergrid-engine": "2.0.4"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/assert": "^1.4.7",
@@ -143,7 +143,25 @@
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
+ :bufferedBuys="bufferedBuys"
160
+ @buyResource="buyResource($event)"
161
+ @unbuyResource="unbuyResource($event)"
162
+ />
146
163
  <Resources
164
+ v-else
147
165
  ref="resources"
148
166
  :transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
149
167
  :isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
@@ -158,16 +176,82 @@
158
176
  :coalStorage="G.coalStorage"
159
177
  :resourceResupply="getResourceResupply()"
160
178
  :resourceResupplyNorth="getResourceResupplyNorth()"
179
+ :bufferedBuys="bufferedBuys"
161
180
  @buyResource="buyResource($event)"
181
+ @unbuyResource="unbuyResource($event)"
162
182
  />
163
183
  </g>
164
184
 
185
+ <!-- Australia: the same uranium-mine selling table laid on its side for
186
+ portrait. A 104x430 strip is the worst possible shape for a full-width
187
+ row — it can only grow until its HEIGHT fills the row, so it ends up a
188
+ sliver with the whole width beside it empty. Turned through 90° the six
189
+ prices run left to right in the same order they run top to bottom, and
190
+ the row is filled by something worth reading. -->
191
+ <g
192
+ v-if="stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
193
+ ref="slotUraniumMines"
194
+ :transform="slotT('uraniumMines') || 'translate(15, 70)'"
195
+ >
196
+ <rect x="0" y="0" width="620" height="146" rx="6" fill="#8aa84a" stroke="#4d6322" stroke-width="3" />
197
+ <text x="310" y="26" text-anchor="middle" font-weight="700" fill="black" style="font-size: 22px">
198
+ Uranium mine market
199
+ </text>
200
+ <g v-for="col in 6" :key="'uraniumCol' + col" :transform="`translate(${10 + (col - 1) * 100}, 0)`">
201
+ <text x="50" y="56" text-anchor="middle" font-weight="700" fill="black" style="font-size: 20px">
202
+ ${{ 8 - col }}
203
+ </text>
204
+ <rect
205
+ x="21"
206
+ y="66"
207
+ width="26"
208
+ height="26"
209
+ rx="3"
210
+ fill="goldenrod"
211
+ stroke="#4d6322"
212
+ stroke-width="1.5"
213
+ />
214
+ <rect
215
+ x="53"
216
+ y="66"
217
+ width="26"
218
+ height="26"
219
+ rx="3"
220
+ fill="goldenrod"
221
+ stroke="#4d6322"
222
+ stroke-width="1.5"
223
+ />
224
+ <circle
225
+ v-if="(G.uraniumMineMarket[6 - col] || 0) >= 1"
226
+ cx="34"
227
+ cy="79"
228
+ r="10"
229
+ fill="#46c655"
230
+ stroke="#1f5c25"
231
+ stroke-width="2"
232
+ />
233
+ <circle
234
+ v-if="(G.uraniumMineMarket[6 - col] || 0) >= 2"
235
+ cx="66"
236
+ cy="79"
237
+ r="10"
238
+ fill="#46c655"
239
+ stroke="#1f5c25"
240
+ stroke-width="2"
241
+ />
242
+ </g>
243
+ <line x1="10" y1="108" x2="610" y2="108" stroke="#4d6322" stroke-width="1" />
244
+ <text x="310" y="132" text-anchor="middle" font-weight="700" fill="#22340f" style="font-size: 18px">
245
+ refill −{{ G.map.uraniumMineResupply[G.players.length - 2][G.step - 1] }}/rnd
246
+ </text>
247
+ </g>
248
+
165
249
  <!-- Australia: uranium-mine selling table. Six price rows $7 (top) →
166
250
  $2 (bottom), two token slots each. Sellers place one token per mine
167
251
  on the highest empty slot; the resource refill removes from the
168
252
  cheap (bottom) end. Lives in the clear upper-left margin. -->
169
253
  <g
170
- v-if="G.map.name === 'Australia' && G.uraniumMineMarket"
254
+ v-if="!stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
171
255
  ref="slotUraniumMines"
172
256
  :transform="slotT('uraniumMines') || 'translate(15, 70)'"
173
257
  >
@@ -310,7 +394,7 @@
310
394
  :ended="gameEnded(G)"
311
395
  :isPlayer="player == playerIndex"
312
396
  :ranking="sortedPlayers.findIndex((x) => x.id == G.players[playerIndex].id) + 1"
313
- :showMoney="player == playerIndex || gameEnded(G) || G.options.showMoney"
397
+ :showMoney="player == playerIndex || moneyIsPublic"
314
398
  :showBid="!G.options.fastBid"
315
399
  :phase="G.phase"
316
400
  :isAustralia="G.map.name === 'Australia'"
@@ -641,7 +725,13 @@ import { Vue, Component, Prop, Watch, Provide, ProvideReactive, Ref } from 'vue-
641
725
  import { MoveName, ended, playersSortedByScore, reconstructState } from 'powergrid-engine';
642
726
  import type { GameState, LogItem, Move, Player } from 'powergrid-engine';
643
727
  import { EventEmitter } from 'events';
644
- import { matchesTurnBuffer, rebaseTurnBuffer, replayTurnBuffer as replayBuffer } from '../util/turn-buffer';
728
+ import {
729
+ bufferedBuyCounts,
730
+ lastBuyIndex,
731
+ matchesTurnBuffer,
732
+ rebaseTurnBuffer,
733
+ replayTurnBuffer as replayBuffer,
734
+ } from '../util/turn-buffer';
645
735
  import { UIData, Preferences } from '../types/ui-data';
646
736
  import { Card, House, Coal, Oil, Garbage, Uranium } from './pieces';
647
737
  import {
@@ -660,6 +750,7 @@ import PowerPlantMarket from './boards/PowerPlantMarket.vue';
660
750
  import PlayerOrder from './boards/PlayerOrder.vue';
661
751
  import CityCount from './boards/CityCount.vue';
662
752
  import Map from './boards/Map.vue';
753
+ import ResourceBoxes from './boards/ResourceBoxes.vue';
663
754
  import Resources from './boards/Resources.vue';
664
755
  import { LogMove } from 'powergrid-engine/src/log';
665
756
  import { Phase, playerTimeUsed, PowerPlant, PowerPlantType, ResourceType } from 'powergrid-engine/src/gamestate';
@@ -719,6 +810,9 @@ const STACK_SLOT_MAX_SCALE: Record<string, number> = {
719
810
  */
720
811
  const STACK_SLOT_MAX_WIDTH: Record<string, number> = {
721
812
  powerPlantMarket: 0.88,
813
+ // The box market is one tall stack of full-width buttons; run edge to edge it
814
+ // reads as though it had been cropped rather than laid out.
815
+ resources: 0.94,
722
816
  };
723
817
 
724
818
  /**
@@ -794,7 +888,8 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
794
888
  PlayerOrder,
795
889
  CityCount,
796
890
  Map,
797
- Resources
891
+ Resources,
892
+ ResourceBoxes
798
893
  },
799
894
  })
800
895
  export default class Game extends Vue {
@@ -822,6 +917,11 @@ export default class Game extends Vue {
822
917
 
823
918
  paused = false;
824
919
 
920
+ // Set once the platform has served a finished game, and never cleared.
921
+ // Reactive on purpose: `_futureState` is not, and the money gate has to
922
+ // re-evaluate as the replay scrubber steps through the game.
923
+ gameIsOver = false;
924
+
825
925
  @Provide()
826
926
  communicator: EventEmitter = new EventEmitter();
827
927
 
@@ -944,11 +1044,19 @@ export default class Game extends Vue {
944
1044
  return state;
945
1045
  }
946
1046
 
947
- replaceState(state: GameState, replaceState = true) {
1047
+ replaceState(state: GameState, replaceState = true, playSound = true) {
948
1048
  if (replaceState) {
949
1049
  this._futureState = state;
950
1050
  }
951
1051
 
1052
+ // Remember that the platform has served us a finished game. The replay
1053
+ // scrubber renders reconstructed MID-game states, whose own `ended()` is
1054
+ // false however long ago the game finished, so it cannot be asked. See
1055
+ // `moneyIsPublic`.
1056
+ if (replaceState && ended(state)) {
1057
+ this.gameIsOver = true;
1058
+ }
1059
+
952
1060
  // if player is selecting resources, keep the state
953
1061
  let player: any;
954
1062
  if (this.player != null) {
@@ -974,7 +1082,8 @@ export default class Game extends Vue {
974
1082
  this.playerOrder.createPieces(this.G!);
975
1083
  this.cityCount.createPieces(this.G!);
976
1084
  this.map.createPieces(this.G!);
977
- this.resources.createPieces(this.G!);
1085
+ // Absent while the portrait layout is showing the box market instead.
1086
+ this.resources?.createPieces(this.G!);
978
1087
  // Pieces are added imperatively, so the groups only reach their
979
1088
  // final size here — the portrait layout must measure after this,
980
1089
  // not on the state change that triggered it.
@@ -982,7 +1091,7 @@ export default class Game extends Vue {
982
1091
  });
983
1092
  }
984
1093
 
985
- if (this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
1094
+ if (playSound && this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
986
1095
  const move = (this.G?.log[this.G?.log.length - 1] as LogMove).move;
987
1096
  if (move.name == MoveName.Pass && this.G.currentPlayers.includes(this.player!)) {
988
1097
  (document.getElementById('notification')!.cloneNode(true) as HTMLAudioElement).play();
@@ -1162,6 +1271,47 @@ export default class Game extends Vue {
1162
1271
  this.sendMove({ name: MoveName.BuyResource, data });
1163
1272
  }
1164
1273
 
1274
+ /**
1275
+ * Take back a resource bought earlier in this same turn (#127): a market's empty
1276
+ * spaces are clickable while the buffer still holds a purchase from that source.
1277
+ *
1278
+ * This is not an engine undo — there is no Undo move any more (2.0.0). A tentative
1279
+ * turn lives only in this buffer, so a purchase is taken back by dropping it and
1280
+ * replaying the rest, exactly as `undo()` drops the last move. Removing a buy only
1281
+ * ever frees money and plant capacity, so the remaining buffer always replays.
1282
+ */
1283
+ unbuyResource(payload: { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean }) {
1284
+ if (this.paused || !this.committedState) {
1285
+ return;
1286
+ }
1287
+
1288
+ const index = lastBuyIndex(this.turnMoves, payload);
1289
+
1290
+ if (index < 0) {
1291
+ return;
1292
+ }
1293
+
1294
+ this.turnMoves.splice(index, 1);
1295
+
1296
+ if (this.turnMoves.length > 0) {
1297
+ const preview = this.replayTurnBuffer();
1298
+ this.emitter.emit('move', [...this.turnMoves]);
1299
+ this.replaceState(preview, false);
1300
+ } else {
1301
+ // Empty buffer: nothing to send — nothing was ever persisted for this turn,
1302
+ // so the last committed state IS the turn start.
1303
+ this.replaceState(this.committedState, false);
1304
+ }
1305
+ }
1306
+
1307
+ /**
1308
+ * Cubes the turn buffer would give back, per source — how many of each market's
1309
+ * empty spaces are clickable.
1310
+ */
1311
+ get bufferedBuys(): Record<string, number> {
1312
+ return bufferedBuyCounts(this.turnMoves);
1313
+ }
1314
+
1165
1315
  bid(bid: number) {
1166
1316
  this.sendMove({ name: MoveName.Bid, data: bid });
1167
1317
  }
@@ -1385,15 +1535,69 @@ export default class Game extends Vue {
1385
1535
  // replays (and the eventual committed log) reproduce the same times.
1386
1536
  this.turnMoves.push({ ...move, time: Date.now() });
1387
1537
 
1538
+ // Preview the move locally BEFORE sending it. Without this the board — and with
1539
+ // it `availableMoves` — stays frozen on the last server reply, so every click
1540
+ // made while a response is in flight is judged against a stale list. Buy one
1541
+ // resource past what your plants can hold and the platform rejects the whole
1542
+ // replayed buffer with "Wrong argument for the command BuyResource"; the
1543
+ // rejected move then stays in the buffer, so every later action resends it and
1544
+ // 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
+ let preview: GameState | null = null;
1549
+
1550
+ if (this.committedState && this.player != undefined) {
1551
+ const buffered = this.turnMoves.length;
1552
+ preview = this.replayTurnBuffer();
1553
+
1554
+ 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;
1558
+ }
1559
+ }
1560
+
1388
1561
  // Send the WHOLE turn so far: the platform is stateless between calls and
1389
1562
  // replays the buffer from the last committed (saved) state.
1390
1563
  this.emitter.emit('move', [...this.turnMoves]);
1564
+
1565
+ // Only adopt a preview that is still TENTATIVE. A committing move (Pass, a bid
1566
+ // that ends an auction) replays hidden outcomes — deck draws, upkeep — on a
1567
+ // state whose deck and seed are stripped, so its preview would flash bogus
1568
+ // results; wait for the server's committed answer instead. The echo of this
1569
+ // same buffer lands next and is authoritative, and it owns the sound, so the
1570
+ // preview stays silent rather than doubling it.
1571
+ if (preview && preview.newTurn === false) {
1572
+ this.replaceState(preview, false, false);
1573
+ }
1391
1574
  }
1392
1575
 
1393
1576
  gameEnded(G: GameState) {
1394
1577
  return ended(G);
1395
1578
  }
1396
1579
 
1580
+ /**
1581
+ * Is every player's cash public?
1582
+ *
1583
+ * Money is hidden information while a game is running, but a finished game has
1584
+ * nothing left to hide — the engine's `stripSecret` reveals it and the end-game
1585
+ * table prints it. The replay scrubber, though, shows a RECONSTRUCTED mid-game
1586
+ * state, and `ended()` on that state is false however long ago the game actually
1587
+ * finished. So stepping back through a finished game blacked out everyone
1588
+ * else's cash, which is most of what makes a replay worth reviewing: you could
1589
+ * not tell whether someone ending the game early would have changed the result
1590
+ * (eric-hu, #130). Ask whether the GAME is over, not whether the step being
1591
+ * displayed is.
1592
+ *
1593
+ * An in-progress game stays hidden, which is the point — the reconstruction
1594
+ * derives every player's true cash from the log, so this gate is the only thing
1595
+ * standing between a mid-game replay and other people's wallets.
1596
+ */
1597
+ get moneyIsPublic(): boolean {
1598
+ return !!this.G && (!!this.G.options.showMoney || this.gameIsOver || ended(this.G));
1599
+ }
1600
+
1397
1601
  canMove() {
1398
1602
  return (
1399
1603
  this.player != undefined &&
@@ -1968,6 +2172,19 @@ export default class Game extends Vue {
1968
2172
  this.stacked = true;
1969
2173
  }
1970
2174
 
2175
+ /**
2176
+ * Entering or leaving the stacked layout swaps the printed resource track for the
2177
+ * box market, which is a different shape entirely. `relayout` sets `stacked` last,
2178
+ * so the swap only reaches the DOM a tick later — by which point the row heights
2179
+ * it just solved for describe the component that is no longer there. Measure again
2180
+ * once the swap has landed. This settles after one extra pass: the second run
2181
+ * leaves `stacked` unchanged, so it does not re-trigger.
2182
+ */
2183
+ @Watch('stacked')
2184
+ onStackedChanged() {
2185
+ this.scheduleRelayout();
2186
+ }
2187
+
1971
2188
  private scheduleRelayout() {
1972
2189
  this.$nextTick(() => this.relayout());
1973
2190
  }
@@ -0,0 +1,274 @@
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
+ <!-- A purchase made this turn can be handed back (#127). The box
120
+ market has no empty spaces to click — the printed track's
121
+ ghosts are exactly what it replaces — so the take-back lives
122
+ on a chip, and stops the click reaching the buy target under
123
+ it. Sits in the free band above the cubes, and appears whether
124
+ or not the row is still buyable: being unable to afford the
125
+ next cube is precisely when you want the last one back. -->
126
+ <g v-if="canUnbuy(row)" class="canClick" @click.stop="$emit('unbuyResource', row.move)">
127
+ <rect
128
+ :x="chipBox(row).hx"
129
+ :y="chipBox(row).hy"
130
+ :width="chipBox(row).hw"
131
+ :height="chipBox(row).hh"
132
+ fill="transparent"
133
+ pointer-events="all"
134
+ />
135
+ <rect
136
+ :x="chipBox(row).x"
137
+ :y="chipBox(row).y"
138
+ :width="chipBox(row).w"
139
+ :height="chipBox(row).h"
140
+ rx="10"
141
+ fill="#f0e6c8"
142
+ stroke="#6b5312"
143
+ stroke-width="2"
144
+ />
145
+ <text
146
+ :x="chipBox(row).x + chipBox(row).w / 2"
147
+ :y="chipBox(row).y + chipBox(row).h / 2 + 8"
148
+ text-anchor="middle"
149
+ font-weight="700"
150
+ fill="#3a2c08"
151
+ :style="`font-size: ${chipBox(row).fontSize}px`"
152
+ >
153
+ − take back
154
+ </text>
155
+ </g>
156
+
157
+ <title>{{ row.title }}</title>
158
+ </g>
159
+ </g>
160
+ </template>
161
+ </g>
162
+ </template>
163
+
164
+ <script lang="ts">
165
+ import { GameState } from 'powergrid-engine';
166
+ import { Component, Prop, Vue } from 'vue-property-decorator';
167
+ import { BuyMove, ResourceBlock, ResourceRow, resourceBlocks } from '../../util/resource-rows';
168
+ import { buySourceKey } from '../../util/turn-buffer';
169
+
170
+ const BOX_W = 700;
171
+ const BOX_H = 104;
172
+ const BOX_GAP = 8;
173
+ /** Block title + column captions above the first box. */
174
+ const HEAD_H = 62;
175
+ const BLOCK_GAP = 30;
176
+ const CUBE_PITCH = 44;
177
+ /** Middle of the band between the price block and the cubes-left count. */
178
+ const CUBE_BAND_CENTRE = 420;
179
+
180
+ /**
181
+ * The portrait resource market.
182
+ *
183
+ * The printed price track is a beautiful object and an unreadable one on a phone:
184
+ * eight to ten columns of three-cube slots, drawn at whatever width the map author
185
+ * chose and then squeezed into a strip. This draws the same market as one box per
186
+ * buyable source, carrying only what a purchase actually needs — what the next cube
187
+ * costs, how many are left at that price, how many are left at all.
188
+ *
189
+ * Every number comes out of the engine state rather than the printed geometry, so the
190
+ * map-specific markets come along for free: Europe's nine price columns, Korea's two
191
+ * sides, Australia's Step-3 CO2 shift (the engine rewrites the price array and the box
192
+ * is only ever a view of it), India's per-step price cap, South Africa's $8 storage
193
+ * pool, USA recharged's $8-from-supply once the track is bare.
194
+ *
195
+ * Which rows a player may actually tap is NOT decided here: `buyableResources` is the
196
+ * engine's own availableMoves, so money, storage capacity, the hybrid tank and Korea's
197
+ * side lock are all accounted for already. The row model itself lives in
198
+ * `util/resource-rows`, where it can be tested against the engine's own arithmetic.
199
+ */
200
+ @Component
201
+ export default class ResourceBoxes extends Vue {
202
+ @Prop() gameState!: GameState;
203
+ @Prop() buyableResources?: BuyMove[];
204
+ @Prop() resourceResupply?: (number | string)[];
205
+ @Prop() resourceResupplyNorth?: (number | string)[];
206
+ @Prop() isUsaRecharged?: boolean;
207
+ /** The seat looking at the board — only needed for Central Europe's Wien discount. */
208
+ @Prop() player?: number;
209
+ /** Purchases still sitting in this turn's buffer, per source (#127). */
210
+ @Prop() bufferedBuys?: Record<string, number>;
211
+
212
+ BOX_W = BOX_W;
213
+ BOX_H = BOX_H;
214
+ BOX_GAP = BOX_GAP;
215
+ HEAD_H = HEAD_H;
216
+ CUBE_BAND_CENTRE = CUBE_BAND_CENTRE;
217
+
218
+ /**
219
+ * Where a row's take-back button sits: in the gap between the price block and the
220
+ * cubes, and big enough for a thumb (~28 css px tall on a phone).
221
+ *
222
+ * The two pool rows carry a long label right across that gap — but they are
223
+ * flat-priced, so nothing sits below them, and the button drops under the label at
224
+ * the same size rather than shrinking.
225
+ */
226
+ chipBox(row: ResourceRow) {
227
+ const labelFillsTheTop = row.label.length > 8;
228
+
229
+ return labelFillsTheTop
230
+ ? { x: 130, y: 36, w: 140, h: 52, hx: 120, hy: 32, hw: 164, hh: 60, fontSize: 22 }
231
+ : { x: 130, y: 6, w: 140, h: 52, hx: 120, hy: 2, hw: 164, hh: 60, fontSize: 22 };
232
+ }
233
+
234
+ /** Does this turn's buffer hold a purchase from this row's source? */
235
+ canUnbuy(row: ResourceRow): boolean {
236
+ return !!this.bufferedBuys && !!this.bufferedBuys[buySourceKey(row.move)];
237
+ }
238
+
239
+ /** Centre the cube cluster in that band, however many cubes there are. */
240
+ cubeX(count: number, n: number): number {
241
+ return CUBE_BAND_CENTRE - ((count - 1) * CUBE_PITCH) / 2 + (n - 1) * CUBE_PITCH;
242
+ }
243
+
244
+ get step(): number {
245
+ return this.gameState.step;
246
+ }
247
+
248
+ get maxPrice(): number {
249
+ const table = (this.gameState.map as { maxPriceAvailable?: number[] }).maxPriceAvailable;
250
+ return table ? table[this.gameState.step - 1] : 16;
251
+ }
252
+
253
+ get blocks(): (ResourceBlock & { y: number })[] {
254
+ let y = 0;
255
+ return resourceBlocks(this.gameState, {
256
+ player: this.player,
257
+ buyable: this.buyableResources,
258
+ resupply: this.resourceResupply,
259
+ resupplyNorth: this.resourceResupplyNorth,
260
+ isUsaRecharged: this.isUsaRecharged,
261
+ }).map((block) => {
262
+ const placed = { ...block, y };
263
+ y += HEAD_H + block.rows.length * (BOX_H + BOX_GAP) - BOX_GAP + BLOCK_GAP;
264
+ return placed;
265
+ });
266
+ }
267
+ }
268
+ </script>
269
+
270
+ <style lang="scss" scoped>
271
+ .canClick {
272
+ cursor: pointer;
273
+ }
274
+ </style>