powergrid-engine 1.11.0 → 1.12.1

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.1",
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];
@@ -94,9 +95,12 @@ export function setup(
94
95
  seed = seed ?? Math.random().toString();
95
96
  const rng = seedrandom(seed);
96
97
 
97
- const chosenMap = cloneDeep(
98
- variant == 'original' ? maps.find((m) => m.name == map)! : mapsRecharged.find((m) => m.name == map)!
99
- );
98
+ const chosenMapRaw =
99
+ variant == 'original' ? maps.find((m) => m.name == map) : mapsRecharged.find((m) => m.name == map);
100
+ if (!chosenMapRaw) {
101
+ throw new Error(`Map "${map}" not found for variant "${variant}"`);
102
+ }
103
+ const chosenMap = cloneDeep(chosenMapRaw);
100
104
 
101
105
  let actualMarket: PowerPlant[];
102
106
  let futureMarket: PowerPlant[];
@@ -260,7 +264,14 @@ export function setup(
260
264
  const region = regions[Math.floor(rng() * regions.length)];
261
265
  if (
262
266
  playRegions.size == 0 ||
263
- regionConnections[regions.indexOf(region)].some((con) => playRegions.has(con))
267
+ regionConnections[regions.indexOf(region)].some((con) => playRegions.has(con)) ||
268
+ // UK & Ireland: regions on the two islands have no edges between
269
+ // them (no sea connection). Skipping the connectivity check lets
270
+ // the random selection span both islands; the cross-island
271
+ // surcharge handles the disconnect at build time. Without this,
272
+ // requiring 5-of-6 regions for 5p would loop forever (GB has 4
273
+ // regions, IE has 2).
274
+ chosenMap.name === 'UK & Ireland'
264
275
  ) {
265
276
  playRegions.add(region);
266
277
 
@@ -333,6 +344,8 @@ export function setup(
333
344
  const oilPricesNorth = chosenMap.oilPricesNorth ? cloneDeep(chosenMap.oilPricesNorth) : undefined;
334
345
  const garbagePricesNorth = chosenMap.garbagePricesNorth ? cloneDeep(chosenMap.garbagePricesNorth) : undefined;
335
346
 
347
+ const isSouthAfrica = (forceMap || finalMap).name == 'South Africa';
348
+
336
349
  const G: GameState = {
337
350
  map: forceMap || finalMap,
338
351
  players,
@@ -343,6 +356,9 @@ export function setup(
343
356
  oilSupply,
344
357
  garbageSupply,
345
358
  uraniumSupply,
359
+ // South Africa: separate coal pool below the market. Starts empty;
360
+ // used coal returns here; refill draws from here first.
361
+ coalStorage: isSouthAfrica ? 0 : undefined,
346
362
  coalResupply,
347
363
  oilResupply,
348
364
  garbageResupply,
@@ -383,6 +399,8 @@ export function setup(
383
399
  citiesToStep2:
384
400
  (forceMap || finalMap).name == 'Baden-Württemberg'
385
401
  ? citiesToStep2BadenWurttemberg[numPlayers - 2]
402
+ : (forceMap || finalMap).name == 'UK & Ireland'
403
+ ? citiesToStep2UKIreland[numPlayers - 2]
386
404
  : citiesToStep2[numPlayers - 2],
387
405
  citiesToEndGame: citiesToEndGame[numPlayers - 2],
388
406
  resourceResupply: [
@@ -755,7 +773,17 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
755
773
  }
756
774
  }
757
775
 
758
- addPowerPlant(G);
776
+ if (G.map.name == 'Europe') {
777
+ // Europe: do NOT draw a replacement from the deck.
778
+ // The future market shrinks from 5 to 4; reorganize
779
+ // the remaining 8 plants so actual stays at 4.
780
+ const market = [...G.actualMarket, ...G.futureMarket];
781
+ market.sort((a, b) => a.number - b.number);
782
+ G.actualMarket = market.slice(0, 4);
783
+ G.futureMarket = market.slice(4);
784
+ } else {
785
+ addPowerPlant(G);
786
+ }
759
787
  }
760
788
  }
761
789
 
@@ -883,13 +911,26 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
883
911
  G.garbageSupply -= garbageResupplyNorthValue;
884
912
  }
885
913
 
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;
914
+ // South Africa pulls from coalStorage first, then coalSupply.
915
+ // Other maps just pull from coalSupply.
916
+ let coalResupplyValue: number;
917
+ if (G.coalStorage !== undefined) {
918
+ const wantCoal = Math.min(G.coalResupply![G.players.length - 2][G.step - 1], coalCapSouth);
919
+ const fromStorage = Math.min(G.coalStorage, wantCoal);
920
+ const fromSupply = Math.min(G.coalSupply, wantCoal - fromStorage);
921
+ coalResupplyValue = fromStorage + fromSupply;
922
+ G.coalMarket += coalResupplyValue;
923
+ G.coalStorage -= fromStorage;
924
+ G.coalSupply -= fromSupply;
925
+ } else {
926
+ coalResupplyValue = Math.min(
927
+ G.coalSupply,
928
+ G.coalResupply![G.players.length - 2][G.step - 1],
929
+ coalCapSouth
930
+ );
931
+ G.coalMarket += coalResupplyValue;
932
+ G.coalSupply -= coalResupplyValue;
933
+ }
893
934
 
894
935
  let oilResupplyValue: number;
895
936
  if (G.map.name == 'Middle East') {
@@ -1284,6 +1325,11 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
1284
1325
  price = coalPrices[coalPrices.length - G.coalMarketNorth!];
1285
1326
  player.coalLeft++;
1286
1327
  G.coalMarketNorth!--;
1328
+ } else if (move.data.fromStorage) {
1329
+ // South Africa: $8 flat from the storage pool below the market.
1330
+ price = 8;
1331
+ player.coalLeft++;
1332
+ G.coalStorage!--;
1287
1333
  } else if (G.coalMarket == 0) {
1288
1334
  price = 8;
1289
1335
  player.coalLeft++;
@@ -1375,8 +1421,14 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
1375
1421
  player.money -= move.data.price;
1376
1422
 
1377
1423
  if (G.options.trackTotalSpent) {
1378
- player.totalSpentCities += 10 + position * 5;
1379
- player.totalSpentConnections += move.data.price - (10 + position * 5);
1424
+ const cityData = G.map.cities.find((c) => c.name == move.data.name)!;
1425
+ if (cityData.singleOccupancy) {
1426
+ // SA cross-border: no house base — full price is "connection".
1427
+ player.totalSpentConnections += move.data.price;
1428
+ } else {
1429
+ player.totalSpentCities += 10 + position * 5;
1430
+ player.totalSpentConnections += move.data.price - (10 + position * 5);
1431
+ }
1380
1432
  }
1381
1433
 
1382
1434
  G.log.push({
@@ -1417,7 +1469,12 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
1417
1469
  switch (resourceType) {
1418
1470
  case ResourceType.Coal:
1419
1471
  player.coalLeft--;
1420
- G.coalSupply++;
1472
+ // SA: used coal returns to the separate storage pool below the market.
1473
+ if (G.coalStorage !== undefined) {
1474
+ G.coalStorage++;
1475
+ } else {
1476
+ G.coalSupply++;
1477
+ }
1421
1478
  break;
1422
1479
 
1423
1480
  case ResourceType.Oil:
@@ -1484,6 +1541,10 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
1484
1541
  G.coalMarketNorth!++;
1485
1542
  const coalPrices = G.coalPricesNorth!;
1486
1543
  price = coalPrices[coalPrices.length - G.coalMarketNorth!];
1544
+ } else if (lastMove.data.fromStorage) {
1545
+ price = 8;
1546
+ player.coalLeft--;
1547
+ G.coalStorage!++;
1487
1548
  } else if (lastMove.fromSupply) {
1488
1549
  price = 8;
1489
1550
  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;