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.
@@ -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" />
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) => {
@@ -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 {
@@ -0,0 +1,246 @@
1
+ import type { GameState } from 'powergrid-engine';
2
+
3
+ /**
4
+ * The row model behind the portrait resource market (`boards/ResourceBoxes.vue`).
5
+ *
6
+ * Kept out of the component so it can be tested against the engine directly: every
7
+ * row's `price` is the money the engine will actually take for the next cube bought
8
+ * from that source, and `resource-rows.spec.ts` asserts exactly that by buying one.
9
+ *
10
+ * Written without optional chaining: webpack 4 parses the unit-test bundle and
11
+ * rejects `?.` (same note as `util/turn-buffer.ts`).
12
+ */
13
+
14
+ export type BuyMove = { resource: string; side?: 'north' | 'south'; fromStorage?: boolean };
15
+
16
+ export interface ResourceRow {
17
+ label: string;
18
+ color: string;
19
+ /** What a tap sends. Identical in shape to the engine's BuyResource payload. */
20
+ move: BuyMove;
21
+ /** What the engine charges for the next cube here, or null when it is empty. */
22
+ price: number | null;
23
+ /** The price once the cubes at the current price run out. */
24
+ next: number | null;
25
+ /** How many cubes are still on sale at `price`. Zero for a flat pool. */
26
+ atPrice: number;
27
+ /** Cubes left in this source altogether. */
28
+ cubes: number;
29
+ buyable: boolean;
30
+ /** On the board but above the per-step price cap, so nobody may buy it yet. */
31
+ capped: boolean;
32
+ /** A flat-price pool with no track behind it: every cube costs the same. */
33
+ flat: boolean;
34
+ title: string;
35
+ }
36
+
37
+ export interface ResourceBlock {
38
+ title?: string;
39
+ restock?: string;
40
+ rows: ResourceRow[];
41
+ }
42
+
43
+ export const RESOURCE_COLORS: Record<string, string> = {
44
+ coal: '#6b2028',
45
+ oil: '#15130f',
46
+ garbage: '#f2d024',
47
+ uranium: '#dc2626',
48
+ };
49
+
50
+ /** The engine's own defaults, for maps that do not author their own arrays. */
51
+ const DEFAULT_PRICES: Record<string, number[]> = {
52
+ coal: [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8],
53
+ oil: [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8],
54
+ garbage: [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8],
55
+ uranium: [1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16],
56
+ };
57
+
58
+ const FLAT_POOL_PRICE = 8;
59
+
60
+ export interface RowOptions {
61
+ /** The seat reading the board — only needed for Central Europe's Wien discount. */
62
+ player?: number;
63
+ buyable?: BuyMove[];
64
+ /** Resupply for this step, indexed coal / oil / garbage / uranium. */
65
+ resupply?: (number | string)[];
66
+ resupplyNorth?: (number | string)[];
67
+ isUsaRecharged?: boolean;
68
+ }
69
+
70
+ function capitalize(s: string): string {
71
+ return s[0].toUpperCase() + s.slice(1);
72
+ }
73
+
74
+ function isKorea(G: GameState): boolean {
75
+ return G.coalPricesNorth !== undefined;
76
+ }
77
+
78
+ /** India and friends only sell up to a per-step price. 16 = no cap in practice. */
79
+ function maxPrice(G: GameState): number {
80
+ const table = (G.map as { maxPriceAvailable?: number[] }).maxPriceAvailable;
81
+ return table ? table[G.step - 1] : 16;
82
+ }
83
+
84
+ function pricesFor(G: GameState, resource: string, north: boolean): number[] {
85
+ const bag = G as unknown as Record<string, number[] | undefined>;
86
+ const authored = bag[resource + 'Prices' + (north ? 'North' : '')];
87
+ return authored !== undefined ? authored : DEFAULT_PRICES[resource];
88
+ }
89
+
90
+ function cubesIn(G: GameState, resource: string, north: boolean): number {
91
+ const bag = G as unknown as Record<string, number | undefined>;
92
+ const count = bag[resource + 'Market' + (north ? 'North' : '')];
93
+ return count === undefined ? 0 : count;
94
+ }
95
+
96
+ function sameMove(a: BuyMove, b: BuyMove): boolean {
97
+ return a.resource === b.resource && a.side === b.side && !!a.fromStorage === !!b.fromStorage;
98
+ }
99
+
100
+ /**
101
+ * Central Europe sells garbage a dollar cheaper to whoever holds Wien — and the
102
+ * engine really does charge the lower price, not just offer the move. The box shows
103
+ * the price THIS player would pay, because that is the only number a tap can act on.
104
+ */
105
+ function wienDiscount(G: GameState, resource: string, player: number | undefined): number {
106
+ if (resource !== 'garbage' || G.map.name !== 'Central Europe' || player === undefined) return 0;
107
+ const me = G.players[player];
108
+ if (!me) return 0;
109
+ return me.cities.some((c) => c.name === 'Wien') ? 1 : 0;
110
+ }
111
+
112
+ function marketRow(G: GameState, resource: string, north: boolean, opts: RowOptions): ResourceRow {
113
+ const prices = pricesFor(G, resource, north);
114
+ const cubes = cubesIn(G, resource, north);
115
+ // Cubes fill from the dear end, so the next one sold is at `length - cubes`.
116
+ // Clamped only so a market holding more cubes than its track has slots reads as
117
+ // the cheapest price rather than as a blank — the engine indexes the same array
118
+ // the same way, so it could not price such a state either. Middle East's "surplus
119
+ // oil" is not that case: its track is the full 24 and the surplus IS the $1 end.
120
+ const idx = Math.max(0, prices.length - cubes);
121
+
122
+ let price: number | null = null;
123
+ let atPrice = 0;
124
+ let next: number | null = null;
125
+
126
+ if (cubes > 0) {
127
+ price = prices[idx] - (north ? 0 : wienDiscount(G, resource, opts.player));
128
+ for (let i = idx; i < prices.length && prices[i] === prices[idx]; i++) atPrice++;
129
+ for (let i = idx; i < prices.length; i++) {
130
+ if (prices[i] !== prices[idx]) {
131
+ next = prices[i] - (north ? 0 : wienDiscount(G, resource, opts.player));
132
+ break;
133
+ }
134
+ }
135
+ }
136
+
137
+ const move: BuyMove = isKorea(G) ? { resource, side: north ? 'north' : 'south' } : { resource };
138
+
139
+ return {
140
+ label: capitalize(resource),
141
+ color: RESOURCE_COLORS[resource],
142
+ move,
143
+ price,
144
+ next,
145
+ atPrice,
146
+ cubes,
147
+ buyable: !!opts.buyable && opts.buyable.some((b) => sameMove(b, move)),
148
+ capped: price !== null && price > maxPrice(G),
149
+ flat: false,
150
+ title:
151
+ cubes > 0
152
+ ? `${capitalize(resource)} — $${price} each, ${atPrice} at that price, ${cubes} left`
153
+ : `${capitalize(resource)} — market empty`,
154
+ };
155
+ }
156
+
157
+ /** South Africa's storage pool and USA recharged's supply: a flat $8, no track. */
158
+ function flatRow(G: GameState, cubes: number, fromStorage: boolean, label: string, opts: RowOptions): ResourceRow {
159
+ const move: BuyMove = fromStorage ? { resource: 'coal', fromStorage: true } : { resource: 'coal' };
160
+ return {
161
+ label,
162
+ color: RESOURCE_COLORS.coal,
163
+ move,
164
+ price: cubes > 0 ? FLAT_POOL_PRICE : null,
165
+ next: null,
166
+ atPrice: 0,
167
+ cubes,
168
+ buyable: !!opts.buyable && opts.buyable.some((b) => sameMove(b, move)),
169
+ capped: cubes > 0 && FLAT_POOL_PRICE > maxPrice(G),
170
+ flat: true,
171
+ title: `${label} — $${FLAT_POOL_PRICE} each, ${cubes} left`,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Does this map sell uranium on the main track at all? Australia moves it to a
177
+ * separate mine market and Bremen has no nuclear, so on both the row would be a
178
+ * permanently empty box.
179
+ */
180
+ function uraniumOnTrack(G: GameState): boolean {
181
+ return G.map.name !== 'Australia' && G.map.name !== 'Bremen';
182
+ }
183
+
184
+ function rowsFor(G: GameState, north: boolean, opts: RowOptions): ResourceRow[] {
185
+ const rows: ResourceRow[] = [];
186
+
187
+ // USA recharged keeps selling coal at a flat $8 out of the supply once the printed
188
+ // track is bare, so the row would otherwise read "out" while the move is on offer.
189
+ if (!north && opts.isUsaRecharged && G.coalMarket === 0 && G.coalSupply > 0) {
190
+ rows.push(flatRow(G, G.coalSupply, false, 'Coal (from supply)', opts));
191
+ } else {
192
+ rows.push(marketRow(G, 'coal', north, opts));
193
+ }
194
+
195
+ // South Africa: used coal returns to a storage pool below the market and can
196
+ // always be bought back at $8, alongside whatever the track is charging. It sits
197
+ // directly under the market coal rather than at the bottom of the list, so the two
198
+ // coal prices are read together — the track is usually the cheaper of the two and
199
+ // that should be obvious without hunting.
200
+ if (!north && G.coalStorage !== undefined) {
201
+ rows.push(flatRow(G, G.coalStorage, true, 'Coal (from storage)', opts));
202
+ }
203
+
204
+ rows.push(marketRow(G, 'oil', north, opts));
205
+ rows.push(marketRow(G, 'garbage', north, opts));
206
+
207
+ // Korea's north side has no uranium row.
208
+ if (!north && uraniumOnTrack(G)) {
209
+ rows.push(marketRow(G, 'uranium', north, opts));
210
+ }
211
+
212
+ return rows;
213
+ }
214
+
215
+ /**
216
+ * The resupply table is indexed coal / oil / garbage / uranium, and the track rows
217
+ * are drawn in that order — so the numbers line up with the boxes below them. Rows
218
+ * that are not part of the printed track (the flat pools) get no number.
219
+ */
220
+ function restockText(table: (number | string)[] | undefined, rows: ResourceRow[]): string | undefined {
221
+ if (!table) return undefined;
222
+ const numbers = rows.filter((r) => !r.flat).map((r, i) => table[i]);
223
+ return numbers.every((n) => n === undefined) ? undefined : numbers.filter((n) => n !== undefined).join(' / ');
224
+ }
225
+
226
+ export function resourceBlocks(G: GameState, opts: RowOptions = {}): ResourceBlock[] {
227
+ const blocks: ResourceBlock[] = [];
228
+
229
+ if (isKorea(G)) {
230
+ const northRows = rowsFor(G, true, opts);
231
+ blocks.push({
232
+ title: 'North Market',
233
+ restock: restockText(opts.resupplyNorth, northRows),
234
+ rows: northRows,
235
+ });
236
+ }
237
+
238
+ const southRows = rowsFor(G, false, opts);
239
+ blocks.push({
240
+ title: isKorea(G) ? 'South Market' : undefined,
241
+ restock: restockText(opts.resupply, southRows),
242
+ rows: southRows,
243
+ });
244
+
245
+ return blocks;
246
+ }