powergrid-viewer 1.6.0 → 1.8.0

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": "1.6.0",
3
+ "version": "1.8.0",
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": "1.10.0"
21
+ "powergrid-engine": "1.12.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/assert": "^1.4.7",
@@ -50,8 +50,8 @@
50
50
  "public/audio"
51
51
  ],
52
52
  "scripts": {
53
- "serve": "vue-cli-service serve",
54
- "build": "vue-cli-service build",
53
+ "serve": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve",
54
+ "build": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",
55
55
  "test:unit": "vue-cli-service test:unit",
56
56
  "package": "vue-cli-service build --target lib --name powergrid-viewer src/wrapper.ts"
57
57
  }
@@ -49,12 +49,13 @@
49
49
 
50
50
  <Map
51
51
  ref="map"
52
- :transform="`translate(${G.map.mapPosition[0]}, ${G.map.mapPosition[1]})`"
52
+ :transform="mapTransform"
53
53
  :playerColors="playerColors"
54
54
  :cities="G.map.cities"
55
55
  :connections="G.map.connections"
56
56
  :polygons="G.map.polygons"
57
57
  :buildableCities="getBuildableCities()"
58
+ :devBackdrop="G.map.devBackdrop"
58
59
  @build="build($event)"
59
60
  />
60
61
 
@@ -68,6 +69,7 @@
68
69
  G.map.name == 'Middle East' ? Math.max(G.oilMarket - G.oilPrices.filter((p) => p > 1).length, 0) : 0
69
70
  "
70
71
  :buyableResources="buyableResources()"
72
+ :coalStorage="G.coalStorage"
71
73
  :resourceResupply="getResourceResupply()"
72
74
  @buyResource="buyResource($event)"
73
75
  />
@@ -287,6 +289,9 @@
287
289
  <li><strong>One</strong> player per city</li>
288
290
  <li>
289
291
  Resource Resupply: <strong>{{ G.resourceResupply[0] }}</strong>
292
+ <template v-if="G.resourceResupplyNorth">
293
+ (S), <strong>{{ G.resourceResupplyNorth[0] }}</strong> (N)
294
+ </template>
290
295
  </li>
291
296
  <li>Bureaucracy: remove <strong>highest</strong> power plant from market</li>
292
297
  </ul>
@@ -301,6 +306,9 @@
301
306
  <li><strong>Two</strong> players per city</li>
302
307
  <li>
303
308
  Resource Resupply: <strong>{{ G.resourceResupply[1] }}</strong>
309
+ <template v-if="G.resourceResupplyNorth">
310
+ (S), <strong>{{ G.resourceResupplyNorth[1] }}</strong> (N)
311
+ </template>
304
312
  </li>
305
313
  <li>Bureaucracy: remove <strong>highest</strong> power plant from market</li>
306
314
  </ul>
@@ -312,6 +320,9 @@
312
320
  <li><strong>Three</strong> players per city</li>
313
321
  <li>
314
322
  Resource Resupply: <strong>{{ G.resourceResupply[2] }}</strong>
323
+ <template v-if="G.resourceResupplyNorth">
324
+ (S), <strong>{{ G.resourceResupplyNorth[2] }}</strong> (N)
325
+ </template>
315
326
  </li>
316
327
  <li>Bureaucracy: remove <strong>lowest</strong> power plant from market</li>
317
328
  <li>All power plants available for auction</li>
@@ -329,7 +340,7 @@
329
340
  <br />
330
341
  <div>
331
342
  <strong>Map Specific Rules:</strong><br />
332
- <span style="white-space: pre">{{ G.map.mapSpecificRules }}</span>
343
+ <span style="white-space: pre-wrap">{{ G.map.mapSpecificRules }}</span>
333
344
  </div>
334
345
  </template>
335
346
  <br />
@@ -621,8 +632,15 @@ export default class Game extends Vue {
621
632
  }
622
633
  }
623
634
 
624
- buyResource(resource: ResourceType) {
625
- this.sendMove({ name: MoveName.BuyResource, data: { resource } });
635
+ buyResource(payload: { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean }) {
636
+ const data: { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean } = { resource: payload.resource };
637
+ if (payload.side) {
638
+ data.side = payload.side;
639
+ }
640
+ if (payload.fromStorage) {
641
+ data.fromStorage = payload.fromStorage;
642
+ }
643
+ this.sendMove({ name: MoveName.BuyResource, data });
626
644
  }
627
645
 
628
646
  bid(bid: number) {
@@ -862,13 +880,13 @@ export default class Game extends Vue {
862
880
  return !!availableMoves[MoveName.ChoosePowerPlant];
863
881
  }
864
882
 
865
- buyableResources() {
883
+ buyableResources(): { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean }[] {
866
884
  if (!this.canMove()) return [];
867
885
 
868
886
  const currentPlayer = this.G!.players[this.player!];
869
887
  const availableMoves = currentPlayer.availableMoves!;
870
888
 
871
- return !!availableMoves[MoveName.BuyResource] && availableMoves[MoveName.BuyResource]!.map((m) => m.resource) || [];
889
+ return availableMoves[MoveName.BuyResource] || [];
872
890
  }
873
891
 
874
892
  canBuyResource(resource?: ResourceType) {
@@ -1084,6 +1102,19 @@ export default class Game extends Vue {
1084
1102
  return playersSortedByScore(this.G!);
1085
1103
  }
1086
1104
 
1105
+ get mapTransform() {
1106
+ if (!this.G?.map) return '';
1107
+ const [mx, my] = this.G.map.mapPosition!;
1108
+ const rotation = this.G.map.mapRotation;
1109
+ if (!rotation) return `translate(${mx}, ${my})`;
1110
+ const cities = this.G.map.cities;
1111
+ const xs = cities.map((c) => c.x);
1112
+ const ys = cities.map((c) => c.y);
1113
+ const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
1114
+ const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
1115
+ return `translate(${mx}, ${my}) rotate(${rotation}, ${cx}, ${cy})`;
1116
+ }
1117
+
1087
1118
  get adjustedPlayerOrder() {
1088
1119
  if (!this.G) {
1089
1120
  return [];
@@ -1,5 +1,16 @@
1
1
  <template>
2
- <g>
2
+ <g @click="onPickCoord">
3
+ <!-- Dev-only backdrop for coord-picking. See GameMap.devBackdrop. -->
4
+ <image
5
+ v-if="devBackdrop"
6
+ :href="devBackdrop.src"
7
+ x="0"
8
+ y="0"
9
+ :width="devBackdrop.width"
10
+ :height="devBackdrop.height"
11
+ :opacity="devBackdrop.opacity != null ? devBackdrop.opacity : 0.5"
12
+ />
13
+
3
14
  <!-- <template v-for="polygon in polygons">
4
15
  <polygon
5
16
  :key="'pol_ ' + polygon.region"
@@ -17,6 +28,27 @@
17
28
  <circle :key="city.name + '_circle'" r="20" :cx="city.x" :cy="city.y" fill="gray" stroke="black">
18
29
  <title>{{ city.name }}</title>
19
30
  </circle>
31
+ <!-- South Africa cross-border spaces: render a small red pennant
32
+ above the city. Marks a single-occupancy space ($30 total cost). -->
33
+ <g v-if="city.singleOccupancy" :key="city.name + '_flag'">
34
+ <line
35
+ :x1="city.x + 12"
36
+ :y1="city.y - 12"
37
+ :x2="city.x + 12"
38
+ :y2="city.y - 32"
39
+ stroke="black"
40
+ stroke-width="1.5"
41
+ />
42
+ <polygon
43
+ :points="`${city.x + 12},${city.y - 32} ${city.x + 26},${city.y - 28} ${city.x + 12},${
44
+ city.y - 24
45
+ }`"
46
+ fill="red"
47
+ stroke="black"
48
+ stroke-width="1"
49
+ />
50
+ <title>{{ city.name }} — cross-border space (1 house max, 30 Elektro total cost)</title>
51
+ </g>
20
52
  </template>
21
53
 
22
54
  <template v-for="connection in connections">
@@ -132,6 +164,7 @@ export default class Map extends Vue {
132
164
  @Prop() connections?: Connection[];
133
165
  @Prop() playerColors?: string[];
134
166
  @Prop() buildableCities?: string[];
167
+ @Prop() devBackdrop?: { src: string; width: number; height: number; opacity?: number };
135
168
 
136
169
  @Inject() preferences!: Preferences;
137
170
 
@@ -192,5 +225,15 @@ export default class Map extends Vue {
192
225
  build(city: City) {
193
226
  this.$emit('build', city);
194
227
  }
228
+
229
+ onPickCoord(e: MouseEvent) {
230
+ if (!this.devBackdrop) return;
231
+ const g = e.currentTarget as SVGGElement;
232
+ const ctm = g.getScreenCTM();
233
+ if (!ctm) return;
234
+ const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
235
+ // eslint-disable-next-line no-console
236
+ console.log(`{ name: Cities.TODO, region: Regions.TODO, x: ${Math.round(pt.x)}, y: ${Math.round(pt.y)} },`);
237
+ }
195
238
  }
196
239
  </script>
@@ -1,5 +1,73 @@
1
1
  <template>
2
2
  <g>
3
+ <!-- Korea: North resource market (above the standard market block).
4
+ The South market re-uses the existing rendering below; Korea-specific
5
+ cube positions are populated in createPieces. -->
6
+ <template v-if="isKorea">
7
+ <text x="20" y="-110" font-weight="700" fill="black" style="font-size: 22px">North Market</text>
8
+ <rect width="760" height="80" x="20" y="-100" rx="3" fill="#c89c3a" />
9
+ <template v-for="index in 8">
10
+ <rect
11
+ :key="'north_resources' + index"
12
+ width="70"
13
+ height="70"
14
+ :x="25 + 85 * (index - 1)"
15
+ y="-95"
16
+ rx="2"
17
+ fill="darkgoldenrod"
18
+ />
19
+ <circle
20
+ :key="'north_resourcesCircle' + index"
21
+ r="10"
22
+ :cx="92 + 85 * (index - 1)"
23
+ cy="-92"
24
+ fill="yellow"
25
+ />
26
+ <text
27
+ :key="'north_resourcesText' + index"
28
+ text-anchor="middle"
29
+ style="font-size: 16px; font-family: monospace"
30
+ :x="92 + 85 * (index - 1)"
31
+ y="-92"
32
+ fill="darkgoldenrod"
33
+ >
34
+ {{ index }}
35
+ </text>
36
+ </template>
37
+ <template v-for="coal in coalsNorth">
38
+ <Coal
39
+ :key="coal.id"
40
+ :pieceId="coal.id"
41
+ :targetState="{ x: coal.x, y: coal.y }"
42
+ :canClick="!coal.transparent && canBuyResource('coal', coal.side, coal.fromStorage)"
43
+ :transparent="coal.transparent"
44
+ :scale="0.08"
45
+ @click="buyResource('coal', coal.side, coal.fromStorage)"
46
+ />
47
+ </template>
48
+ <template v-for="oil in oilsNorth">
49
+ <Oil
50
+ :key="oil.id"
51
+ :pieceId="oil.id"
52
+ :targetState="{ x: oil.x, y: oil.y }"
53
+ :canClick="!oil.transparent && canBuyResource('oil', oil.side)"
54
+ :transparent="oil.transparent"
55
+ @click="buyResource('oil', oil.side)"
56
+ />
57
+ </template>
58
+ <template v-for="garbage in garbagesNorth">
59
+ <Garbage
60
+ :key="garbage.id"
61
+ :pieceId="garbage.id"
62
+ :targetState="{ x: garbage.x, y: garbage.y }"
63
+ :canClick="!garbage.transparent && canBuyResource('garbage', garbage.side)"
64
+ :transparent="garbage.transparent"
65
+ @click="buyResource('garbage', garbage.side)"
66
+ />
67
+ </template>
68
+ <text x="20" y="35" font-weight="700" fill="black" style="font-size: 22px">South Market</text>
69
+ </template>
70
+
3
71
  <text v-if="resourceResupply[0] < 10" x="30" y="20" font-weight="600" fill="black" style="font-size: 24px"
4
72
  >Resource Resupply:</text
5
73
  >
@@ -24,9 +92,18 @@
24
92
  </text>
25
93
  <Uranium :pieceId="-1" :targetState="{ x: 428, y: 12 }" :canClick="false" :transparent="false" />
26
94
 
27
- <rect v-if="!isIndiaResourceMarket" width="760" height="80" x="20" y="40" rx="3" fill="goldenrod" />
95
+ <rect
96
+ v-if="!isIndiaResourceMarket && !isNinePriceMarket"
97
+ width="760"
98
+ height="80"
99
+ x="20"
100
+ y="40"
101
+ rx="3"
102
+ fill="goldenrod"
103
+ />
104
+ <rect v-if="isNinePriceMarket" width="780" height="80" x="20" y="40" rx="3" fill="goldenrod" />
28
105
  <rect v-if="isIndiaResourceMarket" width="680" height="80" x="20" y="40" rx="3" fill="goldenrod" />
29
- <template v-for="index in 8">
106
+ <template v-for="index in isNinePriceMarket ? 9 : 8">
30
107
  <rect
31
108
  :key="'resources' + index"
32
109
  width="70"
@@ -47,7 +124,7 @@
47
124
  >
48
125
  {{ index }}
49
126
  </text>
50
- <g :key="'lines' + index" v-if="!isIndiaResourceMarket">
127
+ <g :key="'lines' + index" v-if="!isIndiaResourceMarket && !isNinePriceMarket">
51
128
  <line :x1="25 + 85 * (index - 1)" y1="68" :x2="95 + 85 * (index - 1)" y2="68" stroke="goldenrod" />
52
129
  <line :x1="25 + 85 * (index - 1)" y1="92" :x2="95 + 85 * (index - 1)" y2="92" stroke="goldenrod" />
53
130
 
@@ -85,7 +162,7 @@
85
162
  </g>
86
163
  </template>
87
164
 
88
- <template v-if="!isIndiaResourceMarket">
165
+ <template v-if="!isIndiaResourceMarket && !isNinePriceMarket">
89
166
  <rect width="30" height="30" x="705" y="45" rx="2" fill="darkgoldenrod" />
90
167
  <circle r="10" cx="732" cy="48" fill="yellow" />
91
168
  <text
@@ -159,15 +236,43 @@
159
236
  <Coal :pieceId="-1" :targetState="{ x: 858, y: 57 }" :canClick="false" :transparent="true" :scale="0.2" />
160
237
  </template>
161
238
 
239
+ <!-- South Africa: coal storage pool below the market. Always-available $8
240
+ flat buy. Used coal returns here; market refills draw from here first. -->
241
+ <template v-if="coalStorage !== undefined">
242
+ <rect
243
+ width="180"
244
+ height="70"
245
+ x="795"
246
+ y="45"
247
+ rx="2"
248
+ fill="chocolate"
249
+ stroke="sandybrown"
250
+ stroke-width="4px"
251
+ />
252
+ <circle r="10" cx="973" cy="45" fill="yellow" />
253
+ <text
254
+ text-anchor="middle"
255
+ style="font-size: 16px; font-family: monospace"
256
+ x="973"
257
+ y="45"
258
+ fill="darkgoldenrod"
259
+ >
260
+ 8
261
+ </text>
262
+ <text text-anchor="start" style="font-size: 11px; font-family: monospace" x="800" y="42" fill="black">
263
+ Coal storage
264
+ </text>
265
+ </template>
266
+
162
267
  <template v-for="coal in coals">
163
268
  <Coal
164
269
  :key="coal.id"
165
270
  :pieceId="coal.id"
166
271
  :targetState="{ x: coal.x, y: coal.y }"
167
- :canClick="!coal.transparent && canBuyResource('coal')"
272
+ :canClick="!coal.transparent && canBuyResource('coal', coal.side, coal.fromStorage)"
168
273
  :transparent="coal.transparent"
169
274
  :scale="isIndiaResourceMarket ? 0.06 : 0.08"
170
- @click="buyResource('coal')"
275
+ @click="buyResource('coal', coal.side, coal.fromStorage)"
171
276
  />
172
277
  </template>
173
278
 
@@ -176,9 +281,9 @@
176
281
  :key="oil.id"
177
282
  :pieceId="oil.id"
178
283
  :targetState="{ x: oil.x, y: oil.y }"
179
- :canClick="!oil.transparent && canBuyResource('oil')"
284
+ :canClick="!oil.transparent && canBuyResource('oil', oil.side)"
180
285
  :transparent="oil.transparent"
181
- @click="buyResource('oil')"
286
+ @click="buyResource('oil', oil.side)"
182
287
  />
183
288
  </template>
184
289
 
@@ -187,10 +292,10 @@
187
292
  :key="garbage.id"
188
293
  :pieceId="garbage.id"
189
294
  :targetState="{ x: garbage.x, y: garbage.y }"
190
- :canClick="!garbage.transparent && canBuyResource('garbage')"
295
+ :canClick="!garbage.transparent && canBuyResource('garbage', garbage.side)"
191
296
  :transparent="garbage.transparent"
192
297
  :scale="isIndiaResourceMarket ? 0.8 : 1"
193
- @click="buyResource('garbage')"
298
+ @click="buyResource('garbage', garbage.side)"
194
299
  />
195
300
  </template>
196
301
 
@@ -199,9 +304,9 @@
199
304
  :key="uranium.id"
200
305
  :pieceId="uranium.id"
201
306
  :targetState="{ x: uranium.x, y: uranium.y }"
202
- :canClick="!uranium.transparent && canBuyResource('uranium')"
307
+ :canClick="!uranium.transparent && canBuyResource('uranium', uranium.side)"
203
308
  :transparent="uranium.transparent"
204
- @click="buyResource('uranium')"
309
+ @click="buyResource('uranium', uranium.side)"
205
310
  />
206
311
  </template>
207
312
 
@@ -222,7 +327,29 @@
222
327
 
223
328
  <template v-if="!preferences.disableHelp">
224
329
  <rect
225
- v-if="buyableResources.length > 0"
330
+ v-if="!isKorea && buyableResources.length > 0"
331
+ x="15"
332
+ y="35"
333
+ width="770"
334
+ height="90"
335
+ rx="2"
336
+ fill="none"
337
+ stroke="blue"
338
+ stroke-width="2px"
339
+ />
340
+ <rect
341
+ v-if="isKorea && hasBuyableNorth"
342
+ x="15"
343
+ y="-105"
344
+ width="770"
345
+ height="90"
346
+ rx="2"
347
+ fill="none"
348
+ stroke="blue"
349
+ stroke-width="2px"
350
+ />
351
+ <rect
352
+ v-if="isKorea && hasBuyableSouth"
226
353
  x="15"
227
354
  y="35"
228
355
  width="770"
@@ -254,7 +381,10 @@ export default class Resources extends Vue {
254
381
  @Prop() isMiddleEast?: boolean;
255
382
  @Prop() isIndiaResourceMarket?: boolean;
256
383
  @Prop() availableSurplusOil?: number;
257
- @Prop() buyableResources?: string[];
384
+ @Prop() buyableResources?: { resource: string, side?: 'north' | 'south', fromStorage?: boolean }[];
385
+ // South Africa: number of coal cubes in the storage pool below the market.
386
+ // Undefined on all other maps (panel is hidden).
387
+ @Prop() coalStorage?: number;
258
388
 
259
389
  @Inject() preferences!: Preferences;
260
390
 
@@ -263,8 +393,142 @@ export default class Resources extends Vue {
263
393
  garbages: Piece[] = [];
264
394
  uraniums: Piece[] = [];
265
395
 
396
+ isKorea: boolean = false;
397
+ isNinePriceMarket: boolean = false;
398
+ coalsNorth: Piece[] = [];
399
+ oilsNorth: Piece[] = [];
400
+ garbagesNorth: Piece[] = [];
401
+
402
+ // Lay out cubes within price spaces ($1..$maxPrice) using the prices array
403
+ // to determine slot count per space. Cubes are indexed cheap→expensive in the
404
+ // prices array, so the cheap end empties as the market depletes.
405
+ // Skips entries with price > maxPrice — used for Korea uranium corners ($10..$16),
406
+ // which the caller renders separately. Europe maxPrice=9 (no corners).
407
+ private buildMainRowPieces(
408
+ prices: number[],
409
+ market: number,
410
+ idPrefix: string,
411
+ y: number,
412
+ options: { side?: 'north' | 'south'; maxPrice?: number } = {},
413
+ ): Piece[] {
414
+ const maxPrice = options.maxPrice ?? 8;
415
+ const groups: { price: number; slots: number; firstIdx: number }[] = [];
416
+ prices.forEach((p, idx) => {
417
+ if (p > maxPrice) return;
418
+ const last = groups[groups.length - 1];
419
+ if (last && last.price === p) last.slots++;
420
+ else groups.push({ price: p, slots: 1, firstIdx: idx });
421
+ });
422
+
423
+ const pieces: Piece[] = [];
424
+ // Width within a price space across which cubes are distributed. Bigger
425
+ // value → cubes within the same price space appear farther apart.
426
+ const cubeAreaW = 64;
427
+ groups.forEach((g) => {
428
+ const psCenter = 60 + 85 * (g.price - 1);
429
+ for (let s = 0; s < g.slots; s++) {
430
+ const i = g.firstIdx + s;
431
+ const x = g.slots === 1
432
+ ? psCenter
433
+ : psCenter - cubeAreaW / 2 + (cubeAreaW * (s + 0.5)) / g.slots;
434
+ pieces.push({
435
+ id: `${idPrefix}_${i}`,
436
+ x,
437
+ y,
438
+ transparent: i < (prices.length - market),
439
+ side: options.side,
440
+ });
441
+ }
442
+ });
443
+ return pieces;
444
+ }
445
+
266
446
  createPieces(gameState: GameState) {
267
- if (gameState) {
447
+ if (!gameState) return;
448
+
449
+ const isKorea = gameState.coalMarketNorth !== undefined;
450
+ this.isKorea = isKorea;
451
+
452
+ if (isKorea) {
453
+ // SOUTH market — main row coal/oil/garbage at the existing y positions.
454
+ this.coals = this.buildMainRowPieces(
455
+ gameState.coalPrices!, gameState.coalMarket, 'coal', 48, { side: 'south' },
456
+ );
457
+ this.oils = this.buildMainRowPieces(
458
+ gameState.oilPrices!, gameState.oilMarket, 'oil', 70, { side: 'south' },
459
+ );
460
+ this.garbages = this.buildMainRowPieces(
461
+ gameState.garbagePrices!, gameState.garbageMarket, 'garbage', 94, { side: 'south' },
462
+ );
463
+
464
+ // SOUTH uranium: $1..$8 sit between the oil (y=70) and garbage (y=94) rows
465
+ // so they don't visually overlap with oil. $10/$12/$14/$16 go in the corner
466
+ // 2x2 grid (top row at y=52, bottom row at y=91).
467
+ this.uraniums = [];
468
+ const uPrices = gameState.uraniumPrices!;
469
+ uPrices.forEach((p, i) => {
470
+ let x: number, y: number;
471
+ if (p <= 8) {
472
+ x = 60 + 85 * (p - 1);
473
+ y = 85;
474
+ } else {
475
+ // $10 → (710, 52), $12 → (750, 52), $14 → (710, 91), $16 → (750, 91)
476
+ const cornerX = (p === 10 || p === 14) ? 710 : 750;
477
+ const cornerY = (p === 10 || p === 12) ? 52 : 91;
478
+ x = cornerX;
479
+ y = cornerY;
480
+ }
481
+ this.uraniums.push({
482
+ id: `uranium_${i}`,
483
+ x, y,
484
+ transparent: i < (uPrices.length - gameState.uraniumMarket),
485
+ side: 'south',
486
+ });
487
+ });
488
+
489
+ // NORTH market — placed above the South so its rect sits at y=-100..-20.
490
+ // Cube rows match the South layout's offsets within the rect (top/mid/bottom).
491
+ this.coalsNorth = this.buildMainRowPieces(
492
+ gameState.coalPricesNorth!, gameState.coalMarketNorth!, 'coal_north', -92, { side: 'north' },
493
+ );
494
+ this.oilsNorth = this.buildMainRowPieces(
495
+ gameState.oilPricesNorth!, gameState.oilMarketNorth!, 'oil_north', -70, { side: 'north' },
496
+ );
497
+ this.garbagesNorth = this.buildMainRowPieces(
498
+ gameState.garbagePricesNorth!, gameState.garbageMarketNorth!, 'garbage_north', -46, { side: 'north' },
499
+ );
500
+
501
+ return;
502
+ }
503
+
504
+ // Europe and North America: 9 price spaces ($1..$9), per-price slot count
505
+ // varies by resource, and uranium tops out at $9 (no $10/$12/$14/$16 corner).
506
+ // Rows are spaced apart (uranium between oil and garbage) so each cube
507
+ // is comfortably clickable: oil 70 → uranium 92 → garbage 106.
508
+ if (gameState.map?.name === 'Europe' || gameState.map?.name === 'North America') {
509
+ this.isNinePriceMarket = true;
510
+ this.coals = this.buildMainRowPieces(
511
+ gameState.coalPrices!, gameState.coalMarket, 'coal', 48, { maxPrice: 9 },
512
+ );
513
+ this.oils = this.buildMainRowPieces(
514
+ gameState.oilPrices!, gameState.oilMarket, 'oil', 70, { maxPrice: 9 },
515
+ );
516
+ this.uraniums = this.buildMainRowPieces(
517
+ gameState.uraniumPrices!, gameState.uraniumMarket, 'uranium', 92, { maxPrice: 9 },
518
+ );
519
+ this.garbages = this.buildMainRowPieces(
520
+ gameState.garbagePrices!, gameState.garbageMarket, 'garbage', 106, { maxPrice: 9 },
521
+ );
522
+ return;
523
+ }
524
+ this.isNinePriceMarket = false;
525
+
526
+ // Non-Korea: original logic below (unchanged).
527
+ this.coalsNorth = [];
528
+ this.oilsNorth = [];
529
+ this.garbagesNorth = [];
530
+
531
+ {
268
532
  this.coals = [];
269
533
  if (gameState.map?.name == 'India') {
270
534
  Array(24)
@@ -322,6 +586,19 @@ export default class Resources extends Vue {
322
586
  });
323
587
  }
324
588
 
589
+ // South Africa: storage pool cubes. Always $8, clickable when buyable.
590
+ if (gameState.coalStorage !== undefined) {
591
+ range(gameState.coalStorage).forEach((i) => {
592
+ this.coals.push({
593
+ id: 'coal_storage_' + i,
594
+ x: 800 + (i % 8) * 20 + (i >= 8 && i < 16 ? 10 : 0),
595
+ y: 50 + Math.floor(i / 8) * 20,
596
+ transparent: false,
597
+ fromStorage: true,
598
+ });
599
+ });
600
+ }
601
+
325
602
  this.oils = [];
326
603
  if (gameState.map?.name == 'Middle East') {
327
604
  let maxRegularOil = gameState.oilPrices!.filter(p => p > 1).length;
@@ -435,12 +712,22 @@ export default class Resources extends Vue {
435
712
  }
436
713
  }
437
714
 
438
- canBuyResource(resource: string) {
439
- return !!this.buyableResources!.find(r => r == resource);
715
+ canBuyResource(resource: string, side?: 'north' | 'south', fromStorage?: boolean) {
716
+ return !!this.buyableResources!.find(
717
+ (r) => r.resource == resource && r.side == side && !!r.fromStorage == !!fromStorage,
718
+ );
719
+ }
720
+
721
+ get hasBuyableNorth() {
722
+ return !!this.buyableResources?.some(r => r.side === 'north');
723
+ }
724
+
725
+ get hasBuyableSouth() {
726
+ return !!this.buyableResources?.some(r => r.side === 'south');
440
727
  }
441
728
 
442
- buyResource(resource: string) {
443
- this.$emit('buyResource', resource);
729
+ buyResource(resource: string, side?: 'north' | 'south', fromStorage?: boolean) {
730
+ this.$emit('buyResource', { resource, side, fromStorage });
444
731
  }
445
732
  }
446
733
  </script>