powergrid-viewer 2.0.5 → 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.5",
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",
@@ -156,7 +156,9 @@
156
156
  :buyableResources="buyableResources()"
157
157
  :resourceResupply="getResourceResupply()"
158
158
  :resourceResupplyNorth="getResourceResupplyNorth()"
159
+ :bufferedBuys="bufferedBuys"
159
160
  @buyResource="buyResource($event)"
161
+ @unbuyResource="unbuyResource($event)"
160
162
  />
161
163
  <Resources
162
164
  v-else
@@ -174,7 +176,9 @@
174
176
  :coalStorage="G.coalStorage"
175
177
  :resourceResupply="getResourceResupply()"
176
178
  :resourceResupplyNorth="getResourceResupplyNorth()"
179
+ :bufferedBuys="bufferedBuys"
177
180
  @buyResource="buyResource($event)"
181
+ @unbuyResource="unbuyResource($event)"
178
182
  />
179
183
  </g>
180
184
 
@@ -721,7 +725,13 @@ import { Vue, Component, Prop, Watch, Provide, ProvideReactive, Ref } from 'vue-
721
725
  import { MoveName, ended, playersSortedByScore, reconstructState } from 'powergrid-engine';
722
726
  import type { GameState, LogItem, Move, Player } from 'powergrid-engine';
723
727
  import { EventEmitter } from 'events';
724
- 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';
725
735
  import { UIData, Preferences } from '../types/ui-data';
726
736
  import { Card, House, Coal, Oil, Garbage, Uranium } from './pieces';
727
737
  import {
@@ -1261,6 +1271,47 @@ export default class Game extends Vue {
1261
1271
  this.sendMove({ name: MoveName.BuyResource, data });
1262
1272
  }
1263
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
+
1264
1315
  bid(bid: number) {
1265
1316
  this.sendMove({ name: MoveName.Bid, data: bid });
1266
1317
  }
@@ -116,6 +116,44 @@
116
116
  step {{ step }} sells to ${{ maxPrice }}
117
117
  </text>
118
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
+
119
157
  <title>{{ row.title }}</title>
120
158
  </g>
121
159
  </g>
@@ -126,7 +164,8 @@
126
164
  <script lang="ts">
127
165
  import { GameState } from 'powergrid-engine';
128
166
  import { Component, Prop, Vue } from 'vue-property-decorator';
129
- import { BuyMove, ResourceBlock, resourceBlocks } from '../../util/resource-rows';
167
+ import { BuyMove, ResourceBlock, ResourceRow, resourceBlocks } from '../../util/resource-rows';
168
+ import { buySourceKey } from '../../util/turn-buffer';
130
169
 
131
170
  const BOX_W = 700;
132
171
  const BOX_H = 104;
@@ -167,6 +206,8 @@ export default class ResourceBoxes extends Vue {
167
206
  @Prop() isUsaRecharged?: boolean;
168
207
  /** The seat looking at the board — only needed for Central Europe's Wien discount. */
169
208
  @Prop() player?: number;
209
+ /** Purchases still sitting in this turn's buffer, per source (#127). */
210
+ @Prop() bufferedBuys?: Record<string, number>;
170
211
 
171
212
  BOX_W = BOX_W;
172
213
  BOX_H = BOX_H;
@@ -174,6 +215,27 @@ export default class ResourceBoxes extends Vue {
174
215
  HEAD_H = HEAD_H;
175
216
  CUBE_BAND_CENTRE = CUBE_BAND_CENTRE;
176
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
+
177
239
  /** Centre the cube cluster in that band, however many cubes there are. */
178
240
  cubeX(count: number, n: number): number {
179
241
  return CUBE_BAND_CENTRE - ((count - 1) * CUBE_PITCH) / 2 + (n - 1) * CUBE_PITCH;
@@ -75,10 +75,13 @@
75
75
  :key="coal.id"
76
76
  :pieceId="coal.id"
77
77
  :targetState="{ x: coal.x, y: coal.y }"
78
- :canClick="!coal.transparent && canBuyResource('coal', coal.side, coal.fromStorage)"
78
+ :canClick="
79
+ coal.transparent ? canUnbuy('coal', coal) : canBuyResource('coal', coal.side, coal.fromStorage)
80
+ "
79
81
  :transparent="coal.transparent"
82
+ :restorable="canUnbuy('coal', coal)"
80
83
  :scale="0.08"
81
- @click="buyResource('coal', coal.side, coal.fromStorage)"
84
+ @click="clickResource('coal', coal)"
82
85
  />
83
86
  </template>
84
87
  <template v-for="oil in oilsNorth">
@@ -86,9 +89,10 @@
86
89
  :key="oil.id"
87
90
  :pieceId="oil.id"
88
91
  :targetState="{ x: oil.x, y: oil.y }"
89
- :canClick="!oil.transparent && canBuyResource('oil', oil.side)"
92
+ :canClick="oil.transparent ? canUnbuy('oil', oil) : canBuyResource('oil', oil.side)"
90
93
  :transparent="oil.transparent"
91
- @click="buyResource('oil', oil.side)"
94
+ :restorable="canUnbuy('oil', oil)"
95
+ @click="clickResource('oil', oil)"
92
96
  />
93
97
  </template>
94
98
  <template v-for="garbage in garbagesNorth">
@@ -96,9 +100,12 @@
96
100
  :key="garbage.id"
97
101
  :pieceId="garbage.id"
98
102
  :targetState="{ x: garbage.x, y: garbage.y }"
99
- :canClick="!garbage.transparent && canBuyResource('garbage', garbage.side)"
103
+ :canClick="
104
+ garbage.transparent ? canUnbuy('garbage', garbage) : canBuyResource('garbage', garbage.side)
105
+ "
100
106
  :transparent="garbage.transparent"
101
- @click="buyResource('garbage', garbage.side)"
107
+ :restorable="canUnbuy('garbage', garbage)"
108
+ @click="clickResource('garbage', garbage)"
102
109
  />
103
110
  </template>
104
111
  <text x="20" y="35" font-weight="700" fill="black" style="font-size: 22px">South Market</text>
@@ -326,10 +333,13 @@
326
333
  :key="coal.id"
327
334
  :pieceId="coal.id"
328
335
  :targetState="{ x: coal.x, y: coal.y }"
329
- :canClick="!coal.transparent && canBuyResource('coal', coal.side, coal.fromStorage)"
336
+ :canClick="
337
+ coal.transparent ? canUnbuy('coal', coal) : canBuyResource('coal', coal.side, coal.fromStorage)
338
+ "
330
339
  :transparent="coal.transparent"
340
+ :restorable="canUnbuy('coal', coal)"
331
341
  :scale="isIndiaResourceMarket ? 0.06 : 0.08"
332
- @click="buyResource('coal', coal.side, coal.fromStorage)"
342
+ @click="clickResource('coal', coal)"
333
343
  />
334
344
  </template>
335
345
 
@@ -338,9 +348,10 @@
338
348
  :key="oil.id"
339
349
  :pieceId="oil.id"
340
350
  :targetState="{ x: oil.x, y: oil.y }"
341
- :canClick="!oil.transparent && canBuyResource('oil', oil.side)"
351
+ :canClick="oil.transparent ? canUnbuy('oil', oil) : canBuyResource('oil', oil.side)"
342
352
  :transparent="oil.transparent"
343
- @click="buyResource('oil', oil.side)"
353
+ :restorable="canUnbuy('oil', oil)"
354
+ @click="clickResource('oil', oil)"
344
355
  />
345
356
  </template>
346
357
 
@@ -349,10 +360,11 @@
349
360
  :key="garbage.id"
350
361
  :pieceId="garbage.id"
351
362
  :targetState="{ x: garbage.x, y: garbage.y }"
352
- :canClick="!garbage.transparent && canBuyResource('garbage', garbage.side)"
363
+ :canClick="garbage.transparent ? canUnbuy('garbage', garbage) : canBuyResource('garbage', garbage.side)"
353
364
  :transparent="garbage.transparent"
365
+ :restorable="canUnbuy('garbage', garbage)"
354
366
  :scale="isIndiaResourceMarket ? 0.8 : 1"
355
- @click="buyResource('garbage', garbage.side)"
367
+ @click="clickResource('garbage', garbage)"
356
368
  />
357
369
  </template>
358
370
 
@@ -361,9 +373,10 @@
361
373
  :key="uranium.id"
362
374
  :pieceId="uranium.id"
363
375
  :targetState="{ x: uranium.x, y: uranium.y }"
364
- :canClick="!uranium.transparent && canBuyResource('uranium', uranium.side)"
376
+ :canClick="uranium.transparent ? canUnbuy('uranium', uranium) : canBuyResource('uranium', uranium.side)"
365
377
  :transparent="uranium.transparent"
366
- @click="buyResource('uranium', uranium.side)"
378
+ :restorable="canUnbuy('uranium', uranium)"
379
+ @click="clickResource('uranium', uranium)"
367
380
  />
368
381
  </template>
369
382
 
@@ -426,6 +439,7 @@ import { Vue, Component, Prop, Inject } from 'vue-property-decorator';
426
439
  import { Coal, Garbage, Oil, Uranium } from '../pieces';
427
440
  import { Piece, Preferences } from '../../types/ui-data';
428
441
  import { range } from 'lodash';
442
+ import { buySourceKey } from '../../util/turn-buffer';
429
443
 
430
444
  @Component({
431
445
  components: {
@@ -443,6 +457,9 @@ export default class Resources extends Vue {
443
457
  // South Africa: number of coal cubes in the storage pool below the market.
444
458
  // Undefined on all other maps (panel is hidden).
445
459
  @Prop() coalStorage?: number;
460
+ // Purchases still sitting in this turn's buffer, per source (#127). Each one can be
461
+ // taken back by clicking the empty space it left behind.
462
+ @Prop() bufferedBuys?: Record<string, number>;
446
463
 
447
464
  @Inject() preferences!: Preferences;
448
465
 
@@ -452,6 +469,7 @@ export default class Resources extends Vue {
452
469
  uraniums: Piece[] = [];
453
470
 
454
471
  isKorea: boolean = false;
472
+ coalComesFromSupply: boolean = false;
455
473
  isNinePriceMarket: boolean = false;
456
474
  // Australia: coal/oil/garbage-only market that can reach $10 after the Step 3
457
475
  // CO2 tax. co2TaxActive closes the $1/$2 columns once that shift has happened.
@@ -501,6 +519,7 @@ export default class Resources extends Vue {
501
519
  x,
502
520
  y,
503
521
  transparent: i < (prices.length - market),
522
+ ghostRank: (prices.length - market) - i,
504
523
  side: options.side,
505
524
  });
506
525
  }
@@ -511,6 +530,10 @@ export default class Resources extends Vue {
511
530
  createPieces(gameState: GameState) {
512
531
  if (!gameState) return;
513
532
 
533
+ // USA recharged: once the printed track is bare, coal is bought from the $8
534
+ // supply pile, and a cube taken back returns THERE — so the empty track spaces
535
+ // must not offer to give it back (#127).
536
+ this.coalComesFromSupply = !!this.isUsaRecharged && gameState.coalMarket === 0 && gameState.coalSupply > 0;
514
537
  this.isAustraliaMarket = gameState.map?.name === 'Australia';
515
538
  this.co2TaxActive = this.isAustraliaMarket && gameState.step >= 3;
516
539
  this.isBremenMarket = gameState.map?.name === 'Bremen';
@@ -551,6 +574,7 @@ export default class Resources extends Vue {
551
574
  id: `uranium_${i}`,
552
575
  x, y,
553
576
  transparent: i < (uPrices.length - gameState.uraniumMarket),
577
+ ghostRank: (uPrices.length - gameState.uraniumMarket) - i,
554
578
  side: 'south',
555
579
  });
556
580
  });
@@ -665,7 +689,8 @@ export default class Resources extends Vue {
665
689
  id: 'coal_' + i,
666
690
  x: 672 - 17 * index - 17 * Math.floor(index / 4),
667
691
  y: 48,
668
- transparent: i >= gameState!.coalMarket
692
+ transparent: i >= gameState!.coalMarket,
693
+ ghostRank: i - (gameState!.coalMarket) + 1,
669
694
  });
670
695
  });
671
696
  } else {
@@ -677,6 +702,7 @@ export default class Resources extends Vue {
677
702
  x: 668 - 23.5 * i - 14.5 * Math.floor(i / 3),
678
703
  y: 48,
679
704
  transparent: i >= gameState!.coalMarket,
705
+ ghostRank: i - (gameState!.coalMarket) + 1,
680
706
  });
681
707
  });
682
708
  }
@@ -720,6 +746,7 @@ export default class Resources extends Vue {
720
746
  x: 651 - 16 * adjustedIndex - 37 * Math.floor(adjustedIndex / 3),
721
747
  y: 70,
722
748
  transparent: i >= availableRegularOil,
749
+ ghostRank: i - (availableRegularOil) + 1,
723
750
  });
724
751
  });
725
752
  } else {
@@ -731,6 +758,7 @@ export default class Resources extends Vue {
731
758
  x: 651 - 16 * i - 37 * Math.floor(i / 3),
732
759
  y: 70,
733
760
  transparent: i >= gameState!.oilMarket,
761
+ ghostRank: i - (gameState!.oilMarket) + 1,
734
762
  });
735
763
  });
736
764
  }
@@ -746,6 +774,7 @@ export default class Resources extends Vue {
746
774
  x: 672 - 17 * index - 17 * Math.floor(index / 4),
747
775
  y: 94,
748
776
  transparent: i >= gameState!.garbageMarket,
777
+ ghostRank: i - (gameState!.garbageMarket) + 1,
749
778
  });
750
779
  });
751
780
  } else {
@@ -757,6 +786,7 @@ export default class Resources extends Vue {
757
786
  x: 668 - 23.5 * i - 14.5 * Math.floor(i / 3),
758
787
  y: 94,
759
788
  transparent: i >= gameState!.garbageMarket,
789
+ ghostRank: i - (gameState!.garbageMarket) + 1,
760
790
  });
761
791
  });
762
792
  }
@@ -770,7 +800,8 @@ export default class Resources extends Vue {
770
800
  id: 'uranium_' + i,
771
801
  x: 670 - 85 * i,
772
802
  y: 72,
773
- transparent: i >= gameState!.uraniumMarket
803
+ transparent: i >= gameState!.uraniumMarket,
804
+ ghostRank: i - (gameState!.uraniumMarket) + 1,
774
805
  });
775
806
  });
776
807
  } else {
@@ -783,6 +814,7 @@ export default class Resources extends Vue {
783
814
  x: 750,
784
815
  y: 91,
785
816
  transparent: i >= gameState!.uraniumMarket,
817
+ ghostRank: i - (gameState!.uraniumMarket) + 1,
786
818
  });
787
819
  } else if (i == 1) {
788
820
  this.uraniums.push({
@@ -790,6 +822,7 @@ export default class Resources extends Vue {
790
822
  x: 710,
791
823
  y: 91,
792
824
  transparent: i >= gameState!.uraniumMarket,
825
+ ghostRank: i - (gameState!.uraniumMarket) + 1,
793
826
  });
794
827
  } else if (i == 2) {
795
828
  this.uraniums.push({
@@ -797,6 +830,7 @@ export default class Resources extends Vue {
797
830
  x: 750,
798
831
  y: 52,
799
832
  transparent: i >= gameState!.uraniumMarket,
833
+ ghostRank: i - (gameState!.uraniumMarket) + 1,
800
834
  });
801
835
  } else if (i == 3) {
802
836
  this.uraniums.push({
@@ -804,6 +838,7 @@ export default class Resources extends Vue {
804
838
  x: 710,
805
839
  y: 52,
806
840
  transparent: i >= gameState!.uraniumMarket,
841
+ ghostRank: i - (gameState!.uraniumMarket) + 1,
807
842
  });
808
843
  } else {
809
844
  this.uraniums.push({
@@ -811,6 +846,7 @@ export default class Resources extends Vue {
811
846
  x: 1010 - 85 * i,
812
847
  y: 72,
813
848
  transparent: i >= gameState!.uraniumMarket,
849
+ ghostRank: i - (gameState!.uraniumMarket) + 1,
814
850
  });
815
851
  }
816
852
  });
@@ -835,5 +871,33 @@ export default class Resources extends Vue {
835
871
  buyResource(resource: string, side?: 'north' | 'south', fromStorage?: boolean) {
836
872
  this.$emit('buyResource', { resource, side, fromStorage });
837
873
  }
874
+
875
+ /**
876
+ * Can this empty space give a cube back? Buying empties a track from its cheap end,
877
+ * so the `n` spaces nearest the fill line hold the `n` purchases this turn's buffer
878
+ * still has from that source — `ghostRank` counts out from that line.
879
+ */
880
+ canUnbuy(resource: string, piece: Piece) {
881
+ if (!piece.transparent || !piece.ghostRank || !this.bufferedBuys) {
882
+ return false;
883
+ }
884
+
885
+ if (this.coalComesFromSupply && resource === 'coal' && !piece.fromStorage) {
886
+ return false;
887
+ }
888
+
889
+ const buffered = this.bufferedBuys[buySourceKey({ resource, side: piece.side, fromStorage: piece.fromStorage })];
890
+
891
+ return !!buffered && piece.ghostRank <= buffered;
892
+ }
893
+
894
+ /** A cube buys; the empty space it left takes the purchase back. */
895
+ clickResource(resource: string, piece: Piece) {
896
+ if (piece.transparent) {
897
+ this.$emit('unbuyResource', { resource, side: piece.side, fromStorage: piece.fromStorage });
898
+ } else {
899
+ this.buyResource(resource, piece.side, piece.fromStorage);
900
+ }
901
+ }
838
902
  }
839
903
  </script>
@@ -3,7 +3,7 @@
3
3
  :id="elId"
4
4
  :class="[{ canClick: canClick }]"
5
5
  :transform="`translate(${currentX}, ${currentY}) scale(${scale})`"
6
- :opacity="transparent ? 0.3 : 1"
6
+ :opacity="pieceOpacity"
7
7
  @click="canClick && $emit('click')"
8
8
  >
9
9
  <path
@@ -3,7 +3,7 @@
3
3
  :id="elId"
4
4
  :class="[{ canClick: canClick }]"
5
5
  :transform="`translate(${currentX}, ${currentY}) scale(${scale})`"
6
- :opacity="transparent ? 0.3 : 1"
6
+ :opacity="pieceOpacity"
7
7
  @click="canClick && $emit('click')"
8
8
  >
9
9
  <path d="M2,5 A9,5,0 0,1 18,5 L18,15 A9,5 0 0,1 2,15Z" fill="none" stroke="black" stroke-miterlimit="0" />
@@ -3,7 +3,7 @@
3
3
  :id="elId"
4
4
  :class="[{ canClick: canClick }]"
5
5
  :transform="`translate(${currentX}, ${currentY}) scale(${scale})`"
6
- :opacity="transparent ? 0.3 : 1"
6
+ :opacity="pieceOpacity"
7
7
  @click="canClick && $emit('click')"
8
8
  >
9
9
  <path d="M6,4 A6,4,0 0,1 14,4 L14,16 A6,4 0 0,1 6,16Z" fill="none" stroke="black" />
@@ -27,6 +27,12 @@ export default class Piece extends Vue {
27
27
  @Prop()
28
28
  transparent?: boolean;
29
29
 
30
+ // An empty market space holding a purchase that can still be taken back (#127).
31
+ // Drawn brighter than a plain empty space so the click target is visible — there
32
+ // is no hover cursor to discover it with on a phone.
33
+ @Prop()
34
+ restorable?: boolean;
35
+
30
36
  @Inject()
31
37
  readonly communicator!: EventEmitter;
32
38
 
@@ -43,6 +49,14 @@ export default class Piece extends Vue {
43
49
 
44
50
  moving = false;
45
51
 
52
+ get pieceOpacity(): number {
53
+ if (!this.transparent) {
54
+ return 1;
55
+ }
56
+
57
+ return this.restorable ? 0.6 : 0.3;
58
+ }
59
+
46
60
  startTransitioning() {
47
61
  if (this.transitioning) {
48
62
  return;
@@ -3,7 +3,7 @@
3
3
  :id="elId"
4
4
  :class="[{ canClick: canClick }]"
5
5
  :transform="`translate(${currentX}, ${currentY})`"
6
- :opacity="transparent ? 0.3 : 1"
6
+ :opacity="pieceOpacity"
7
7
  @click="canClick && $emit('click')"
8
8
  >
9
9
  <path d="M3,3 l4,-2 l6,0 l4,2 l0,11 l-4,2 l-6,0 l-4,-2Z" fill="#F00" stroke="black" />
@@ -30,6 +30,11 @@ export interface Piece {
30
30
  // South Africa: this coal cube sits in the storage pool below the market.
31
31
  // Clicking emits buyResource with fromStorage:true (flat $8 buy).
32
32
  fromStorage?: boolean;
33
+ // Empty spaces only: distance from the market's fill line, 1 for the space the
34
+ // next cube would come back to. A purchase made this turn can be taken back by
35
+ // clicking the ghost that holds it (#127), so the first `n` ranks are clickable
36
+ // when the turn buffer holds `n` buys from that source.
37
+ ghostRank?: number;
33
38
  }
34
39
 
35
40
  export enum PieceType {
@@ -1,6 +1,6 @@
1
1
  import { isEqual } from 'lodash';
2
2
  import type { GameState, LogItem, LogMove, Move } from 'powergrid-engine';
3
- import { move as engineMove } from 'powergrid-engine';
3
+ import { move as engineMove, MoveName } from 'powergrid-engine';
4
4
 
5
5
  /**
6
6
  * Pure helpers for the viewer's tentative-turn buffer.
@@ -147,3 +147,68 @@ export function replayTurnBuffer(
147
147
 
148
148
  return { state, applied };
149
149
  }
150
+
151
+ /**
152
+ * Where a resource purchase came from. The same resource bought from the north or the
153
+ * south table, or out of South Africa's storage pool, is a different source with its
154
+ * own cubes — so a decrement must give back a cube from the source that was clicked.
155
+ */
156
+ export interface BuySource {
157
+ resource: string;
158
+ side?: 'north' | 'south';
159
+ fromStorage?: boolean;
160
+ }
161
+
162
+ /** Stable key for a buy source, so counts can be looked up per market row or track. */
163
+ export function buySourceKey(source: BuySource): string {
164
+ return `${source.resource}|${source.side || ''}|${source.fromStorage ? 'storage' : ''}`;
165
+ }
166
+
167
+ function isBuyFrom(move: Move, source: BuySource): boolean {
168
+ const data = move.data as BuySource | undefined;
169
+
170
+ return (
171
+ move.name === MoveName.BuyResource &&
172
+ !!data &&
173
+ data.resource === source.resource &&
174
+ (data.side || '') === (source.side || '') &&
175
+ !!data.fromStorage === !!source.fromStorage
176
+ );
177
+ }
178
+
179
+ /**
180
+ * How many cubes of each source the buffer would give back, keyed by `buySourceKey`.
181
+ * The market draws one ghost per cube taken off it, so this is also how many of those
182
+ * ghosts can be clicked to take a purchase back.
183
+ */
184
+ export function bufferedBuyCounts(turnMoves: Move[]): Record<string, number> {
185
+ const counts: Record<string, number> = {};
186
+
187
+ for (const move of turnMoves) {
188
+ if (move.name !== MoveName.BuyResource || !move.data) {
189
+ continue;
190
+ }
191
+
192
+ const key = buySourceKey(move.data as BuySource);
193
+ counts[key] = (counts[key] || 0) + 1;
194
+ }
195
+
196
+ return counts;
197
+ }
198
+
199
+ /**
200
+ * Index of the purchase a decrement click should remove: the LAST buffered buy from
201
+ * that source. Cubes leave a track cheapest-first, so the last buy is the one the
202
+ * market hands back — and removing a purchase only ever frees money and plant capacity,
203
+ * so the rest of the buffer always replays (verified over 653 mid-buffer removals).
204
+ * Returns -1 when the buffer holds no purchase from that source.
205
+ */
206
+ export function lastBuyIndex(turnMoves: Move[], source: BuySource): number {
207
+ for (let i = turnMoves.length - 1; i >= 0; i--) {
208
+ if (isBuyFrom(turnMoves[i], source)) {
209
+ return i;
210
+ }
211
+ }
212
+
213
+ return -1;
214
+ }