powergrid-engine 1.11.0 → 1.12.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.
@@ -6,6 +6,8 @@ export interface City {
6
6
  x: number;
7
7
  y: number;
8
8
  transregional?: boolean;
9
+ singleOccupancy?: boolean;
10
+ island?: string;
9
11
  }
10
12
  export interface Connection {
11
13
  nodes: string[];
@@ -28,6 +30,7 @@ export interface GameMap {
28
30
  powerPlantMarketPosition?: [number, number];
29
31
  actualMarketWidth?: number;
30
32
  mapPosition?: [number, number];
33
+ mapRotation?: number;
31
34
  buttonsPosition?: [number, number];
32
35
  playerBoardsPosition?: [number, number];
33
36
  supplyPosition?: [number, number];
@@ -51,7 +54,14 @@ export interface GameMap {
51
54
  powerPlantsDeck: PowerPlant[];
52
55
  };
53
56
  regionalPowerPlants?: Record<string, PowerPlant[]>;
57
+ crossIslandSurcharge?: number;
54
58
  mapSpecificRules?: string;
59
+ devBackdrop?: {
60
+ src: string;
61
+ width: number;
62
+ height: number;
63
+ opacity?: number;
64
+ };
55
65
  }
56
66
  export declare const maps: GameMap[];
57
67
  export declare const mapsRecharged: GameMap[];
package/dist/src/maps.js CHANGED
@@ -8,6 +8,7 @@ const benelux_1 = require("./maps/benelux");
8
8
  const brazil_1 = require("./maps/brazil");
9
9
  const centraleurope_1 = require("./maps/centraleurope");
10
10
  const china_1 = require("./maps/china");
11
+ const europe_1 = require("./maps/europe");
11
12
  const france_1 = require("./maps/france");
12
13
  const germany_1 = require("./maps/germany");
13
14
  const indian_1 = require("./maps/indian");
@@ -15,10 +16,13 @@ const italy_1 = require("./maps/italy");
15
16
  // import { map as japan } from './maps/japan';
16
17
  const korea_1 = require("./maps/korea");
17
18
  const middleeast_1 = require("./maps/middleeast");
19
+ const northamerica_1 = require("./maps/northamerica");
18
20
  const northerneurope_1 = require("./maps/northerneurope");
19
21
  const quebec_1 = require("./maps/quebec");
20
22
  const russia_1 = require("./maps/russia");
23
+ const southafrica_1 = require("./maps/southafrica");
21
24
  const spainportugal_1 = require("./maps/spainportugal");
25
+ const ukireland_1 = require("./maps/ukireland");
22
26
  exports.maps = [
23
27
  america_1.map,
24
28
  germany_1.map,
@@ -36,10 +40,12 @@ exports.maps = [
36
40
  badenwurttemberg_1.map,
37
41
  northerneurope_1.map,
38
42
  korea_1.map,
43
+ europe_1.map,
44
+ northamerica_1.map,
45
+ southafrica_1.map,
46
+ ukireland_1.map,
39
47
  // australia,
40
48
  // japan,
41
- // southafrica,
42
- // ukireland,
43
49
  ];
44
50
  exports.mapsRecharged = [
45
51
  america_1.mapRecharged,
@@ -58,9 +64,11 @@ exports.mapsRecharged = [
58
64
  badenwurttemberg_1.map,
59
65
  northerneurope_1.map,
60
66
  korea_1.map,
67
+ europe_1.map,
68
+ northamerica_1.map,
69
+ southafrica_1.map,
70
+ ukireland_1.map,
61
71
  // australia,
62
72
  // china,
63
73
  // japan,
64
- // southafrica,
65
- // ukireland,
66
74
  ];
@@ -23,6 +23,7 @@ export declare namespace Moves {
23
23
  data: {
24
24
  resource: ResourceType;
25
25
  side?: 'north' | 'south';
26
+ fromStorage?: boolean;
26
27
  };
27
28
  fromSupply?: boolean;
28
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "powergrid-engine",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "An engine for Power Grid",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.ts",
@@ -14,6 +14,9 @@ export interface AvailableMoves {
14
14
  // Korea: which side's market this buy option draws from.
15
15
  // Omitted on all other maps.
16
16
  side?: 'north' | 'south';
17
+ // South Africa: $8 flat buy from the coal storage pool below the market.
18
+ // Distinct from the regular market buy (which can also be offered alongside).
19
+ fromStorage?: boolean;
17
20
  }[];
18
21
  [MoveName.Build]?: { name: string; price: number }[];
19
22
  [MoveName.UsePowerPlant]?: {
@@ -91,6 +94,18 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
91
94
  }
92
95
  }
93
96
 
97
+ // No nuclear plants for Republic of Ireland (Green region) on UK&I.
98
+ // The restriction lifts as soon as the player has any non-Green city
99
+ // (Scotland/Wales/England/Northern Ireland = Brown/Yellow/Red/Pink/Orange).
100
+ if (G.map.name == 'UK & Ireland') {
101
+ const playerCities = player.cities.map(
102
+ (c) => G.map.cities.find((c_) => c_.name == c.name)!
103
+ );
104
+ if (playerCities.every((c) => c.region == 'green')) {
105
+ canBid = canBid.filter((p) => p.type != PowerPlantType.Uranium);
106
+ }
107
+ }
108
+
94
109
  if (canBid.length > 0) {
95
110
  moves[MoveName.ChoosePowerPlant] = canBid.map((p) => p.number);
96
111
  }
@@ -138,6 +153,19 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
138
153
  }
139
154
  }
140
155
 
156
+ // No nuclear plants for Republic of Ireland (Green region) on UK&I.
157
+ if (G.map.name == 'UK & Ireland') {
158
+ const playerCities = player.cities.map(
159
+ (c) => G.map.cities.find((c_) => c_.name == c.name)!
160
+ );
161
+ if (
162
+ playerCities.every((c) => c.region == 'green') &&
163
+ G.chosenPowerPlant.type == PowerPlantType.Uranium
164
+ ) {
165
+ moves[MoveName.Bid] = undefined;
166
+ }
167
+ }
168
+
141
169
  if (G.options.fastBid) {
142
170
  if (player.id != G.auctioningPlayer) {
143
171
  moves[MoveName.Pass] = [true];
@@ -160,7 +188,7 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
160
188
  break;
161
189
  }
162
190
 
163
- const toBuy: { resource: ResourceType; side?: 'north' | 'south' }[] = [];
191
+ const toBuy: { resource: ResourceType; side?: 'north' | 'south'; fromStorage?: boolean }[] = [];
164
192
  let maxPriceAvailable: number;
165
193
  if (G.map.maxPriceAvailable) {
166
194
  maxPriceAvailable = G.map.maxPriceAvailable[G.step - 1];
@@ -202,6 +230,21 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
202
230
  }
203
231
  }
204
232
 
233
+ // South Africa: $8 flat coal from the storage pool below the market.
234
+ // Always available alongside the regular market option (not gated on
235
+ // market being empty), as long as there are cubes in storage.
236
+ if (allowSouth && G.coalStorage !== undefined && G.coalStorage > 0) {
237
+ const hybridCapacityUsed =
238
+ player.hybridCapacity > 0 ? Math.max(0, player.oilLeft - player.oilCapacity) : 0;
239
+ if (
240
+ player.money >= 8 &&
241
+ player.coalCapacity + player.hybridCapacity > hybridCapacityUsed + player.coalLeft &&
242
+ 8 <= maxPriceAvailable
243
+ ) {
244
+ toBuy.push({ resource: ResourceType.Coal, fromStorage: true });
245
+ }
246
+ }
247
+
205
248
  if (allowNorth && G.coalMarketNorth! > 0) {
206
249
  const hybridCapacityUsed =
207
250
  player.hybridCapacity > 0 ? Math.max(0, player.oilLeft - player.oilCapacity) : 0;
@@ -341,6 +384,50 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
341
384
  return;
342
385
  }
343
386
 
387
+ // UK & Ireland: starting a network on an island where the player has
388
+ // no city yet pays the first-house base + crossIslandSurcharge. There
389
+ // is no sea edge so dijkstra reports the target city as unreachable
390
+ // (price=9999); we override here. The first build ever (player.cities
391
+ // empty) goes through the normal first-build path and pays no surcharge.
392
+ if (cityData.island && G.map.crossIslandSurcharge !== undefined && player.cities.length > 0) {
393
+ const playerIslands = new Set(
394
+ player.cities
395
+ .map((c) => G.map.cities.find((mc) => mc.name == c.name)?.island)
396
+ .filter((i): i is string => !!i)
397
+ );
398
+ if (!playerIslands.has(cityData.island)) {
399
+ city.price = 10 + othersCount * 5 + G.map.crossIslandSurcharge;
400
+
401
+ if (othersCount == G.step) {
402
+ city.price = 9999;
403
+ }
404
+
405
+ if (player.cities.find((c) => c.name == city.name)) {
406
+ city.price = 9999;
407
+ }
408
+ return;
409
+ }
410
+ }
411
+
412
+ // South Africa's cross-border foreign-country spaces: cap at 1 occupant
413
+ // ever, and the dijkstra path cost (30 via the cross-border edge) is the
414
+ // complete cost — no 10+position*5 house base is added. Players cannot
415
+ // start in one of these (you have to build INTO South Africa first).
416
+ if (cityData.singleOccupancy) {
417
+ if (player.cities.length == 0) {
418
+ city.price = 9999;
419
+ return;
420
+ }
421
+ if (othersCount >= 1) {
422
+ city.price = 9999;
423
+ return;
424
+ }
425
+ if (player.cities.find((c) => c.name == city.name)) {
426
+ city.price = 9999;
427
+ }
428
+ return;
429
+ }
430
+
344
431
  city.price += 10 + othersCount * 5;
345
432
 
346
433
  if (othersCount == G.step) {
@@ -141,6 +141,23 @@ describe('Engine', () => {
141
141
  expect(ended(G)).to.be.false;
142
142
  });
143
143
 
144
+ it('should place UK & Ireland Step 3 card third from last with two plants below it', () => {
145
+ // UK & Ireland rules: the Step 3 card (plant 99) goes at deck.length - 3
146
+ // so two plants sit below it, and Step 3 fires two auctions earlier than
147
+ // a standard "Step 3 at the bottom" deck.
148
+ const G = setup(5, { map: 'UK & Ireland', variant: 'recharged', randomizeMap: false }, 'ukireland-test-seed');
149
+
150
+ const step3Idx = G.powerPlantsDeck.findIndex((p) => p.number === 99);
151
+ expect(step3Idx).to.equal(G.powerPlantsDeck.length - 3);
152
+
153
+ // The two plants below Step 3 are real plants (not another Step 3 card or
154
+ // undefined slots).
155
+ expect(G.powerPlantsDeck[step3Idx + 1]).to.exist;
156
+ expect(G.powerPlantsDeck[step3Idx + 2]).to.exist;
157
+ expect(G.powerPlantsDeck[step3Idx + 1].number).to.not.equal(99);
158
+ expect(G.powerPlantsDeck[step3Idx + 2].number).to.not.equal(99);
159
+ });
160
+
144
161
  it('should allow invalid move when isUndo is true', () => {
145
162
  const game = undo;
146
163
  const options: GameOptions = {
package/src/engine.ts CHANGED
@@ -15,6 +15,7 @@ export const playerColors = ['limegreen', 'mediumorchid', 'red', 'dodgerblue', '
15
15
 
16
16
  const citiesToStep2 = [10, 7, 7, 7, 6];
17
17
  const citiesToStep2BadenWurttemberg = [9, 6, 6, 6, 5];
18
+ const citiesToStep2UKIreland = [7, 7, 7, 7, 6];
18
19
  const citiesToEndGame = [21, 17, 17, 15, 14];
19
20
  const cityIncome = [10, 22, 33, 44, 54, 64, 73, 82, 90, 98, 105, 112, 118, 124, 129, 134, 138, 142, 145, 148, 150, 150];
20
21
  const regionsInPlay = [3, 3, 4, 5, 5];
@@ -260,7 +261,14 @@ export function setup(
260
261
  const region = regions[Math.floor(rng() * regions.length)];
261
262
  if (
262
263
  playRegions.size == 0 ||
263
- regionConnections[regions.indexOf(region)].some((con) => playRegions.has(con))
264
+ regionConnections[regions.indexOf(region)].some((con) => playRegions.has(con)) ||
265
+ // UK & Ireland: regions on the two islands have no edges between
266
+ // them (no sea connection). Skipping the connectivity check lets
267
+ // the random selection span both islands; the cross-island
268
+ // surcharge handles the disconnect at build time. Without this,
269
+ // requiring 5-of-6 regions for 5p would loop forever (GB has 4
270
+ // regions, IE has 2).
271
+ chosenMap.name === 'UK & Ireland'
264
272
  ) {
265
273
  playRegions.add(region);
266
274
 
@@ -333,6 +341,8 @@ export function setup(
333
341
  const oilPricesNorth = chosenMap.oilPricesNorth ? cloneDeep(chosenMap.oilPricesNorth) : undefined;
334
342
  const garbagePricesNorth = chosenMap.garbagePricesNorth ? cloneDeep(chosenMap.garbagePricesNorth) : undefined;
335
343
 
344
+ const isSouthAfrica = (forceMap || finalMap).name == 'South Africa';
345
+
336
346
  const G: GameState = {
337
347
  map: forceMap || finalMap,
338
348
  players,
@@ -343,6 +353,9 @@ export function setup(
343
353
  oilSupply,
344
354
  garbageSupply,
345
355
  uraniumSupply,
356
+ // South Africa: separate coal pool below the market. Starts empty;
357
+ // used coal returns here; refill draws from here first.
358
+ coalStorage: isSouthAfrica ? 0 : undefined,
346
359
  coalResupply,
347
360
  oilResupply,
348
361
  garbageResupply,
@@ -383,6 +396,8 @@ export function setup(
383
396
  citiesToStep2:
384
397
  (forceMap || finalMap).name == 'Baden-Württemberg'
385
398
  ? citiesToStep2BadenWurttemberg[numPlayers - 2]
399
+ : (forceMap || finalMap).name == 'UK & Ireland'
400
+ ? citiesToStep2UKIreland[numPlayers - 2]
386
401
  : citiesToStep2[numPlayers - 2],
387
402
  citiesToEndGame: citiesToEndGame[numPlayers - 2],
388
403
  resourceResupply: [
@@ -755,7 +770,17 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
755
770
  }
756
771
  }
757
772
 
758
- addPowerPlant(G);
773
+ if (G.map.name == 'Europe') {
774
+ // Europe: do NOT draw a replacement from the deck.
775
+ // The future market shrinks from 5 to 4; reorganize
776
+ // the remaining 8 plants so actual stays at 4.
777
+ const market = [...G.actualMarket, ...G.futureMarket];
778
+ market.sort((a, b) => a.number - b.number);
779
+ G.actualMarket = market.slice(0, 4);
780
+ G.futureMarket = market.slice(4);
781
+ } else {
782
+ addPowerPlant(G);
783
+ }
759
784
  }
760
785
  }
761
786
 
@@ -883,13 +908,26 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
883
908
  G.garbageSupply -= garbageResupplyNorthValue;
884
909
  }
885
910
 
886
- const coalResupplyValue = Math.min(
887
- G.coalSupply,
888
- G.coalResupply![G.players.length - 2][G.step - 1],
889
- coalCapSouth
890
- );
891
- G.coalMarket += coalResupplyValue;
892
- G.coalSupply -= coalResupplyValue;
911
+ // South Africa pulls from coalStorage first, then coalSupply.
912
+ // Other maps just pull from coalSupply.
913
+ let coalResupplyValue: number;
914
+ if (G.coalStorage !== undefined) {
915
+ const wantCoal = Math.min(G.coalResupply![G.players.length - 2][G.step - 1], coalCapSouth);
916
+ const fromStorage = Math.min(G.coalStorage, wantCoal);
917
+ const fromSupply = Math.min(G.coalSupply, wantCoal - fromStorage);
918
+ coalResupplyValue = fromStorage + fromSupply;
919
+ G.coalMarket += coalResupplyValue;
920
+ G.coalStorage -= fromStorage;
921
+ G.coalSupply -= fromSupply;
922
+ } else {
923
+ coalResupplyValue = Math.min(
924
+ G.coalSupply,
925
+ G.coalResupply![G.players.length - 2][G.step - 1],
926
+ coalCapSouth
927
+ );
928
+ G.coalMarket += coalResupplyValue;
929
+ G.coalSupply -= coalResupplyValue;
930
+ }
893
931
 
894
932
  let oilResupplyValue: number;
895
933
  if (G.map.name == 'Middle East') {
@@ -1284,6 +1322,11 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
1284
1322
  price = coalPrices[coalPrices.length - G.coalMarketNorth!];
1285
1323
  player.coalLeft++;
1286
1324
  G.coalMarketNorth!--;
1325
+ } else if (move.data.fromStorage) {
1326
+ // South Africa: $8 flat from the storage pool below the market.
1327
+ price = 8;
1328
+ player.coalLeft++;
1329
+ G.coalStorage!--;
1287
1330
  } else if (G.coalMarket == 0) {
1288
1331
  price = 8;
1289
1332
  player.coalLeft++;
@@ -1375,8 +1418,14 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
1375
1418
  player.money -= move.data.price;
1376
1419
 
1377
1420
  if (G.options.trackTotalSpent) {
1378
- player.totalSpentCities += 10 + position * 5;
1379
- player.totalSpentConnections += move.data.price - (10 + position * 5);
1421
+ const cityData = G.map.cities.find((c) => c.name == move.data.name)!;
1422
+ if (cityData.singleOccupancy) {
1423
+ // SA cross-border: no house base — full price is "connection".
1424
+ player.totalSpentConnections += move.data.price;
1425
+ } else {
1426
+ player.totalSpentCities += 10 + position * 5;
1427
+ player.totalSpentConnections += move.data.price - (10 + position * 5);
1428
+ }
1380
1429
  }
1381
1430
 
1382
1431
  G.log.push({
@@ -1417,7 +1466,12 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
1417
1466
  switch (resourceType) {
1418
1467
  case ResourceType.Coal:
1419
1468
  player.coalLeft--;
1420
- G.coalSupply++;
1469
+ // SA: used coal returns to the separate storage pool below the market.
1470
+ if (G.coalStorage !== undefined) {
1471
+ G.coalStorage++;
1472
+ } else {
1473
+ G.coalSupply++;
1474
+ }
1421
1475
  break;
1422
1476
 
1423
1477
  case ResourceType.Oil:
@@ -1484,6 +1538,10 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
1484
1538
  G.coalMarketNorth!++;
1485
1539
  const coalPrices = G.coalPricesNorth!;
1486
1540
  price = coalPrices[coalPrices.length - G.coalMarketNorth!];
1541
+ } else if (lastMove.data.fromStorage) {
1542
+ price = 8;
1543
+ player.coalLeft--;
1544
+ G.coalStorage!++;
1487
1545
  } else if (lastMove.fromSupply) {
1488
1546
  price = 8;
1489
1547
  player.coalLeft--;
package/src/gamestate.ts CHANGED
@@ -19,11 +19,13 @@ export type MapName =
19
19
  | 'Central Europe'
20
20
  | 'Baden-Württemberg'
21
21
  | 'Northern Europe'
22
- | 'Korea';
22
+ | 'Korea'
23
+ | 'Europe'
24
+ | 'North America'
25
+ | 'South Africa'
26
+ | 'UK & Ireland';
23
27
  // | 'Australia'
24
28
  // | 'Japan'
25
- // | 'South Africa'
26
- // | 'UK & Ireland'
27
29
  export type Variant = 'original' | 'recharged';
28
30
 
29
31
  export interface GameOptions {
@@ -121,6 +123,11 @@ export interface GameState {
121
123
  oilSupply: number;
122
124
  garbageSupply: number;
123
125
  uraniumSupply: number;
126
+ // South Africa: coal used to power plants returns here instead of coalSupply.
127
+ // Market refills draw from this pool first, then fall back to coalSupply.
128
+ // Players can always buy a coal cube from here for $8 (on top of the normal
129
+ // market). Undefined on non-SA maps.
130
+ coalStorage?: number;
124
131
  coalMarket: number;
125
132
  oilMarket: number;
126
133
  garbageMarket: number;