powergrid-engine 1.12.0 → 1.13.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.
@@ -9,7 +9,7 @@ const gamestate_1 = require("./gamestate");
9
9
  const move_1 = require("./move");
10
10
  const prices_1 = __importDefault(require("./prices"));
11
11
  function availableMoves(G, player) {
12
- var _a, _b, _c, _d, _e, _f;
12
+ var _a, _b, _c, _d, _e, _f, _g;
13
13
  const moves = {};
14
14
  const lastLog = G.log[G.log.length - 1];
15
15
  if (lastLog.type == 'move' && G.currentPlayers.includes(player.id)) {
@@ -260,9 +260,36 @@ function availableMoves(G, player) {
260
260
  }
261
261
  case gamestate_1.Phase.Building: {
262
262
  if (player.cities.length < 21) {
263
- let toBuild = player.cities.length == 0
264
- ? G.map.cities.map((c) => ({ name: c.name, price: 0 }))
265
- : dijkstra(G, player).map((c) => ({ name: c.name, price: c.price }));
263
+ const isJapan = G.map.name === 'Japan';
264
+ const japanStartingCities = isJapan ? new Set((_g = G.map.startingCities) !== null && _g !== void 0 ? _g : []) : new Set();
265
+ let toBuild;
266
+ if (player.cities.length == 0) {
267
+ // Japan: first house must go in one of the 6 designated starting cities.
268
+ const candidates = isJapan
269
+ ? G.map.cities.filter((c) => japanStartingCities.has(c.name))
270
+ : G.map.cities;
271
+ toBuild = candidates.map((c) => ({ name: c.name, price: 0 }));
272
+ }
273
+ else if (isJapan && G.round === 1) {
274
+ // Japan round 1: any additional house must also be a starting city.
275
+ // Players cannot extend to adjacent non-starting cities until round 2.
276
+ toBuild = G.map.cities
277
+ .filter((c) => japanStartingCities.has(c.name))
278
+ .map((c) => ({ name: c.name, price: 0 }));
279
+ }
280
+ else {
281
+ toBuild = dijkstra(G, player).map((c) => ({ name: c.name, price: c.price }));
282
+ // Japan: a player may start a second disconnected network at any available
283
+ // starting city, paying only the slot cost (no connection fee).
284
+ if (isJapan &&
285
+ countNetworks(G.map.connections, player.cities.map((c) => c.name)) < 2) {
286
+ toBuild.forEach((city) => {
287
+ if (japanStartingCities.has(city.name)) {
288
+ city.price = 0;
289
+ }
290
+ });
291
+ }
292
+ }
266
293
  toBuild.forEach((city) => {
267
294
  const cityData = G.map.cities.find((c) => c.name == city.name);
268
295
  const othersCount = G.players.filter((p) => p.cities.find((c) => city.name == c.name)).length;
@@ -322,10 +349,15 @@ function availableMoves(G, player) {
322
349
  }
323
350
  return;
324
351
  }
325
- city.price += 10 + othersCount * 5;
326
- if (othersCount == G.step) {
352
+ const slotCosts = cityData.slotCosts;
353
+ const maxSlotsThisStep = cityData.stepSlots ? cityData.stepSlots[G.step - 1] : G.step;
354
+ const totalSlots = slotCosts ? slotCosts.length : 3;
355
+ if (othersCount >= maxSlotsThisStep || othersCount >= totalSlots) {
327
356
  city.price = 9999;
328
357
  }
358
+ else {
359
+ city.price += slotCosts ? slotCosts[othersCount] : 10 + othersCount * 5;
360
+ }
329
361
  if (player.cities.find((c) => c.name == city.name)) {
330
362
  city.price = 9999;
331
363
  }
@@ -441,6 +473,32 @@ function availableMoves(G, player) {
441
473
  return moves;
442
474
  }
443
475
  exports.availableMoves = availableMoves;
476
+ function countNetworks(connections, cityNames) {
477
+ const citySet = new Set(cityNames);
478
+ const visited = new Set();
479
+ let count = 0;
480
+ for (const city of cityNames) {
481
+ if (visited.has(city))
482
+ continue;
483
+ count++;
484
+ const stack = [city];
485
+ while (stack.length > 0) {
486
+ const current = stack.pop();
487
+ if (visited.has(current))
488
+ continue;
489
+ visited.add(current);
490
+ for (const conn of connections) {
491
+ if (conn.nodes.includes(current)) {
492
+ const neighbor = conn.nodes.find((n) => n !== current);
493
+ if (citySet.has(neighbor) && !visited.has(neighbor)) {
494
+ stack.push(neighbor);
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
500
+ return count;
501
+ }
444
502
  function dijkstra(G, player) {
445
503
  const nodes = G.map.cities.map((c) => ({
446
504
  name: c.name,
@@ -74,7 +74,11 @@ function setup(numPlayers, { fastBid = false, map = 'USA', variant = 'original',
74
74
  var _a, _b, _c, _d, _e, _f, _g;
75
75
  seed = seed !== null && seed !== void 0 ? seed : Math.random().toString();
76
76
  const rng = seedrandom_1.default(seed);
77
- const chosenMap = lodash_1.cloneDeep(variant == 'original' ? maps_1.maps.find((m) => m.name == map) : maps_1.mapsRecharged.find((m) => m.name == map));
77
+ const chosenMapRaw = variant == 'original' ? maps_1.maps.find((m) => m.name == map) : maps_1.mapsRecharged.find((m) => m.name == map);
78
+ if (!chosenMapRaw) {
79
+ throw new Error(`Map "${map}" not found for variant "${variant}"`);
80
+ }
81
+ const chosenMap = lodash_1.cloneDeep(chosenMapRaw);
78
82
  let actualMarket;
79
83
  let futureMarket;
80
84
  let powerPlantsDeck;
@@ -532,7 +536,7 @@ function move(G, move, playerNumber, isUndo = false) {
532
536
  G.map.name != 'China' &&
533
537
  G.map.name != 'Russia') {
534
538
  if (G.map.name == 'Baden-Württemberg') {
535
- // Baden-Württemberg: remove the two lowest plants
539
+ // Baden-Württemberg: remove the two lowest plants when no one buys.
536
540
  const removed = [];
537
541
  for (let i = 0; i < 2 && G.actualMarket.length > 0; i++) {
538
542
  removed.push(G.actualMarket[0].number);
@@ -2,7 +2,7 @@ import { AvailableMoves } from './available-moves';
2
2
  import { LogItem } from './log';
3
3
  import { GameMap } from './maps';
4
4
  import { Move } from './move';
5
- export declare type MapName = 'USA' | 'Germany' | 'Brazil' | 'Spain & Portugal' | 'France' | 'Italy' | 'Quebec' | 'Middle East' | 'India' | 'China' | 'Benelux' | 'Russia' | 'Central Europe' | 'Baden-Württemberg' | 'Northern Europe' | 'Korea' | 'Europe' | 'North America' | 'South Africa' | 'UK & Ireland';
5
+ export declare type MapName = 'USA' | 'Germany' | 'Brazil' | 'Spain & Portugal' | 'France' | 'Italy' | 'Quebec' | 'Middle East' | 'India' | 'China' | 'Benelux' | 'Russia' | 'Central Europe' | 'Baden-Württemberg' | 'Northern Europe' | 'Korea' | 'Europe' | 'North America' | 'South Africa' | 'UK & Ireland' | 'Japan';
6
6
  export declare type Variant = 'original' | 'recharged';
7
7
  export interface GameOptions {
8
8
  fastBid?: boolean;
@@ -56,8 +56,24 @@ exports.map = {
56
56
  { name: Cities.Sigmarincen, region: Regions.Purple, x: 1111, y: 1803 },
57
57
  { name: Cities.Konstanz, region: Regions.Purple, x: 1088, y: 2163 },
58
58
  { name: Cities.Ulm, region: Regions.Purple, x: 1541, y: 1499 },
59
- { name: Cities.Augsburg, region: Regions.Purple, x: 1830, y: 1505, transregional: true },
60
- { name: Cities.Basel, region: Regions.Green, x: 103, y: 2271, transregional: true },
59
+ {
60
+ name: Cities.Augsburg,
61
+ region: Regions.Purple,
62
+ x: 1830,
63
+ y: 1505,
64
+ transregional: true,
65
+ slotCosts: [15, 20],
66
+ stepSlots: [0, 1, 2],
67
+ },
68
+ {
69
+ name: Cities.Basel,
70
+ region: Regions.Green,
71
+ x: 103,
72
+ y: 2271,
73
+ transregional: true,
74
+ slotCosts: [15, 20],
75
+ stepSlots: [0, 1, 2],
76
+ },
61
77
  { name: Cities.Waldshuttiencen, region: Regions.Green, x: 502, y: 2166 },
62
78
  { name: Cities.Singen, region: Regions.Green, x: 874, y: 2084 },
63
79
  { name: Cities.Tuttlincen, region: Regions.Green, x: 870, y: 1854 },
@@ -70,8 +86,24 @@ exports.map = {
70
86
  { name: Cities.Sinsheim, region: Regions.Red, x: 920, y: 785 },
71
87
  { name: Cities.Heidelber, region: Regions.Red, x: 817, y: 660 },
72
88
  { name: Cities.Mannheim, region: Regions.Red, x: 710, y: 540 },
73
- { name: Cities.Luowigshafen, region: Regions.Red, x: 579, y: 588, transregional: true },
74
- { name: Cities.Nurnberg, region: Regions.Blue, x: 1837, y: 771, transregional: true },
89
+ {
90
+ name: Cities.Luowigshafen,
91
+ region: Regions.Red,
92
+ x: 579,
93
+ y: 588,
94
+ transregional: true,
95
+ slotCosts: [15, 20],
96
+ stepSlots: [0, 1, 2],
97
+ },
98
+ {
99
+ name: Cities.Nurnberg,
100
+ region: Regions.Blue,
101
+ x: 1837,
102
+ y: 771,
103
+ transregional: true,
104
+ slotCosts: [15, 20],
105
+ stepSlots: [0, 1, 2],
106
+ },
75
107
  { name: Cities.Ellwangen, region: Regions.Blue, x: 1637, y: 1030 },
76
108
  { name: Cities.Coppingen, region: Regions.Blue, x: 1387, y: 1233 },
77
109
  { name: Cities.Reutlincen, region: Regions.Blue, x: 1107, y: 1443 },
@@ -81,7 +113,15 @@ exports.map = {
81
113
  { name: Cities.Laha, region: Regions.Yellow, x: 296, y: 1575 },
82
114
  { name: Cities.Badenbaden, region: Regions.Yellow, x: 554, y: 1259 },
83
115
  { name: Cities.Offenburg, region: Regions.Yellow, x: 395, y: 1422 },
84
- { name: Cities.Strasbourg, region: Regions.Yellow, x: 211, y: 1330, transregional: true },
116
+ {
117
+ name: Cities.Strasbourg,
118
+ region: Regions.Yellow,
119
+ x: 211,
120
+ y: 1330,
121
+ transregional: true,
122
+ slotCosts: [15, 20],
123
+ stepSlots: [0, 1, 2],
124
+ },
85
125
  { name: Cities.Pforzheim, region: Regions.Yellow, x: 794, y: 1095 },
86
126
  { name: Cities.Rastatt, region: Regions.Yellow, x: 509, y: 1109 },
87
127
  { name: Cities.Karlsruhf, region: Regions.Yellow, x: 638, y: 974 },
@@ -50,42 +50,94 @@ var Cities;
50
50
  exports.map = {
51
51
  name: 'Japan',
52
52
  cities: [
53
- { name: Cities.Asahikawa, region: Regions.Brown, x: 3384, y: 414 },
54
- { name: Cities.Sapporo, region: Regions.Brown, x: 3028, y: 382 },
55
- { name: Cities.Hakodate, region: Regions.Brown, x: 2622, y: 512 },
53
+ { name: Cities.Asahikawa, region: Regions.Brown, x: 3384, y: 1014 },
54
+ { name: Cities.Sapporo, region: Regions.Brown, x: 3028, y: 982, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
55
+ { name: Cities.Hakodate, region: Regions.Brown, x: 2622, y: 1112 },
56
56
  { name: Cities.Admori, region: Regions.Brown, x: 3818, y: 1336 },
57
- { name: Cities.Morioka, region: Regions.Brown, x: 3636, y: 1646 },
57
+ { name: Cities.Morioka, region: Regions.Brown, x: 3636, y: 1646, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
58
58
  { name: Cities.Akita, region: Regions.Brown, x: 3472, y: 1480 },
59
59
  { name: Cities.Sendai, region: Regions.Brown, x: 3408, y: 1886 },
60
60
  { name: Cities.Niigata, region: Regions.Green, x: 2874, y: 1628 },
61
- { name: Cities.Koriyama, region: Regions.Green, x: 3086, y: 1932 },
61
+ { name: Cities.Koriyama, region: Regions.Green, x: 3086, y: 1932, slotCosts: [10, 15] },
62
62
  { name: Cities.Nagano, region: Regions.Green, x: 2486, y: 1722 },
63
- { name: Cities.Saitama, region: Regions.Green, x: 2450, y: 1986 },
64
- { name: Cities.Tokyo, region: Regions.Green, x: 2524, y: 2122 },
63
+ { name: Cities.Saitama, region: Regions.Green, x: 2450, y: 1986, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
64
+ { name: Cities.Tokyo, region: Regions.Green, x: 2524, y: 2122, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
65
65
  { name: Cities.Chiba, region: Regions.Green, x: 2650, y: 2316 },
66
- { name: Cities.Yokohama, region: Regions.Green, x: 2306, y: 2244 },
66
+ {
67
+ name: Cities.Yokohama,
68
+ region: Regions.Green,
69
+ x: 2306,
70
+ y: 2244,
71
+ slotCosts: [10, 10, 20],
72
+ stepSlots: [2, 2, 3],
73
+ },
67
74
  { name: Cities.Kanazawa, region: Regions.Purple, x: 2207, y: 1385 },
68
- { name: Cities.Toyama, region: Regions.Purple, x: 2257, y: 1595 },
75
+ { name: Cities.Toyama, region: Regions.Purple, x: 2257, y: 1595, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
69
76
  { name: Cities.Kyoto, region: Regions.Purple, x: 1765, y: 1595 },
70
- { name: Cities.Osaka, region: Regions.Purple, x: 1517, y: 1631 },
77
+ { name: Cities.Osaka, region: Regions.Purple, x: 1517, y: 1631, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
71
78
  { name: Cities.Nagoya, region: Regions.Purple, x: 1942, y: 1751 },
72
79
  { name: Cities.Kofu, region: Regions.Purple, x: 2179, y: 1897 },
73
- { name: Cities.Hamamatsu, region: Regions.Purple, x: 1958, y: 2024 },
74
- { name: Cities.Kobe, region: Regions.Yellow, x: 1555, y: 1451 },
75
- { name: Cities.Matsue, region: Regions.Yellow, x: 1457, y: 1006 },
80
+ { name: Cities.Hamamatsu, region: Regions.Purple, x: 1958, y: 2024, slotCosts: [10, 15] },
81
+ { name: Cities.Kobe, region: Regions.Yellow, x: 1555, y: 1451, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
82
+ { name: Cities.Matsue, region: Regions.Yellow, x: 1457, y: 1006, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
76
83
  { name: Cities.Okayama, region: Regions.Yellow, x: 1390, y: 1286 },
77
84
  { name: Cities.Takamatsu, region: Regions.Yellow, x: 1268, y: 1507 },
78
- { name: Cities.Kochi, region: Regions.Yellow, x: 974, y: 1472 },
85
+ { name: Cities.Kochi, region: Regions.Yellow, x: 974, y: 1472, slotCosts: [10, 15] },
79
86
  { name: Cities.Matsuyama, region: Regions.Yellow, x: 1036, y: 1268 },
80
87
  { name: Cities.Hiroshima, region: Regions.Yellow, x: 1103, y: 1078 },
81
88
  { name: Cities.Shimonoseki, region: Regions.Red, x: 856, y: 962 },
82
- { name: Cities.Oita, region: Regions.Red, x: 731, y: 1166 },
83
- { name: Cities.Miyazaki, region: Regions.Red, x: 469, y: 1318 },
89
+ { name: Cities.Oita, region: Regions.Red, x: 731, y: 1166, slotCosts: [10, 15] },
90
+ { name: Cities.Miyazaki, region: Regions.Red, x: 469, y: 1318, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
84
91
  { name: Cities.Kagoshima, region: Regions.Red, x: 157, y: 1255 },
85
92
  { name: Cities.Kumamoto, region: Regions.Red, x: 457, y: 1045 },
86
- { name: Cities.Fukuoka, region: Regions.Red, x: 644, y: 823 },
93
+ { name: Cities.Fukuoka, region: Regions.Red, x: 644, y: 823, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
87
94
  { name: Cities.Nagasaki, region: Regions.Red, x: 313, y: 875 },
88
95
  ],
96
+ layout: 'Portrait',
97
+ adjustRatio: [0.28, 0.28],
98
+ mapPosition: [0, 120],
99
+ resupply: [
100
+ // Coal
101
+ [
102
+ [3, 4, 3],
103
+ [4, 5, 3],
104
+ [5, 6, 4],
105
+ [5, 7, 5],
106
+ [7, 9, 6], // 6P
107
+ ],
108
+ // Oil
109
+ [
110
+ [2, 2, 4],
111
+ [2, 3, 4],
112
+ [3, 4, 5],
113
+ [4, 5, 6],
114
+ [5, 6, 7],
115
+ ],
116
+ // Garbage
117
+ [
118
+ [1, 2, 3],
119
+ [1, 2, 3],
120
+ [2, 3, 4],
121
+ [3, 3, 5],
122
+ [3, 5, 6],
123
+ ],
124
+ // Uranium
125
+ [
126
+ [1, 1, 1],
127
+ [1, 1, 1],
128
+ [1, 2, 2],
129
+ [2, 3, 2],
130
+ [2, 3, 3],
131
+ ],
132
+ ],
133
+ startingResources: [21, 15, 9, 3],
134
+ startingCities: ['Fukuoka', 'Kobe', 'Osaka', 'Sapporo', 'Tokyo', 'Yokohama'],
135
+ mapSpecificRules: 'Each player can have two separate networks.\n' +
136
+ 'First houses must be placed in one of six starting cities: Fukuoka, Kobe, Osaka, Sapporo, Tokyo, or Yokohama.\n' +
137
+ 'Starting cities have two first-connection spots (cost 10 Elektro each); two players can build there in Step 1.\n' +
138
+ 'In Step 3, a third connection spot opens in starting cities (cost 20 Elektro).\n' +
139
+ 'Some smaller cities have only two building spots (costs 10 and 15, or 15 and 20 from Step 2).\n' +
140
+ 'If all spots in every starting city are taken, a player is forced to play with a single network.',
89
141
  connections: [
90
142
  { nodes: [Cities.Nagasaki, Cities.Fukuoka], cost: 10 },
91
143
  { nodes: [Cities.Fukuoka, Cities.Shimonoseki], cost: 10 },
@@ -6,6 +6,8 @@ export interface City {
6
6
  x: number;
7
7
  y: number;
8
8
  transregional?: boolean;
9
+ slotCosts?: number[];
10
+ stepSlots?: number[];
9
11
  singleOccupancy?: boolean;
10
12
  island?: string;
11
13
  }
@@ -56,6 +58,7 @@ export interface GameMap {
56
58
  regionalPowerPlants?: Record<string, PowerPlant[]>;
57
59
  crossIslandSurcharge?: number;
58
60
  mapSpecificRules?: string;
61
+ startingCities?: string[];
59
62
  devBackdrop?: {
60
63
  src: string;
61
64
  width: number;
package/dist/src/maps.js CHANGED
@@ -13,7 +13,7 @@ const france_1 = require("./maps/france");
13
13
  const germany_1 = require("./maps/germany");
14
14
  const indian_1 = require("./maps/indian");
15
15
  const italy_1 = require("./maps/italy");
16
- // import { map as japan } from './maps/japan';
16
+ const japan_1 = require("./maps/japan");
17
17
  const korea_1 = require("./maps/korea");
18
18
  const middleeast_1 = require("./maps/middleeast");
19
19
  const northamerica_1 = require("./maps/northamerica");
@@ -45,7 +45,7 @@ exports.maps = [
45
45
  southafrica_1.map,
46
46
  ukireland_1.map,
47
47
  // australia,
48
- // japan,
48
+ japan_1.map,
49
49
  ];
50
50
  exports.mapsRecharged = [
51
51
  america_1.mapRecharged,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "powergrid-engine",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "An engine for Power Grid",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.ts",
@@ -354,10 +354,41 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
354
354
 
355
355
  case Phase.Building: {
356
356
  if (player.cities.length < 21) {
357
- let toBuild =
358
- player.cities.length == 0
359
- ? G.map.cities.map((c) => ({ name: c.name, price: 0 }))
360
- : dijkstra(G, player).map((c) => ({ name: c.name, price: c.price }));
357
+ const isJapan = G.map.name === 'Japan';
358
+ const japanStartingCities = isJapan ? new Set(G.map.startingCities ?? []) : new Set<string>();
359
+
360
+ let toBuild: { name: string; price: number }[];
361
+ if (player.cities.length == 0) {
362
+ // Japan: first house must go in one of the 6 designated starting cities.
363
+ const candidates = isJapan
364
+ ? G.map.cities.filter((c) => japanStartingCities.has(c.name))
365
+ : G.map.cities;
366
+ toBuild = candidates.map((c) => ({ name: c.name, price: 0 }));
367
+ } else if (isJapan && G.round === 1) {
368
+ // Japan round 1: any additional house must also be a starting city.
369
+ // Players cannot extend to adjacent non-starting cities until round 2.
370
+ toBuild = G.map.cities
371
+ .filter((c) => japanStartingCities.has(c.name))
372
+ .map((c) => ({ name: c.name, price: 0 }));
373
+ } else {
374
+ toBuild = dijkstra(G, player).map((c) => ({ name: c.name, price: c.price }));
375
+
376
+ // Japan: a player may start a second disconnected network at any available
377
+ // starting city, paying only the slot cost (no connection fee).
378
+ if (
379
+ isJapan &&
380
+ countNetworks(
381
+ G.map.connections,
382
+ player.cities.map((c) => c.name)
383
+ ) < 2
384
+ ) {
385
+ toBuild.forEach((city) => {
386
+ if (japanStartingCities.has(city.name)) {
387
+ city.price = 0;
388
+ }
389
+ });
390
+ }
391
+ }
361
392
 
362
393
  toBuild.forEach((city) => {
363
394
  const cityData = G.map.cities.find((c) => c.name == city.name)!;
@@ -428,10 +459,14 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
428
459
  return;
429
460
  }
430
461
 
431
- city.price += 10 + othersCount * 5;
462
+ const slotCosts = cityData.slotCosts;
463
+ const maxSlotsThisStep = cityData.stepSlots ? cityData.stepSlots[G.step - 1] : G.step;
464
+ const totalSlots = slotCosts ? slotCosts.length : 3;
432
465
 
433
- if (othersCount == G.step) {
466
+ if (othersCount >= maxSlotsThisStep || othersCount >= totalSlots) {
434
467
  city.price = 9999;
468
+ } else {
469
+ city.price += slotCosts ? slotCosts[othersCount] : 10 + othersCount * 5;
435
470
  }
436
471
 
437
472
  if (player.cities.find((c) => c.name == city.name)) {
@@ -569,6 +604,31 @@ export function availableMoves(G: GameState, player: Player): AvailableMoves {
569
604
  return moves;
570
605
  }
571
606
 
607
+ function countNetworks(connections: { nodes: string[] }[], cityNames: string[]): number {
608
+ const citySet = new Set(cityNames);
609
+ const visited = new Set<string>();
610
+ let count = 0;
611
+ for (const city of cityNames) {
612
+ if (visited.has(city)) continue;
613
+ count++;
614
+ const stack = [city];
615
+ while (stack.length > 0) {
616
+ const current = stack.pop()!;
617
+ if (visited.has(current)) continue;
618
+ visited.add(current);
619
+ for (const conn of connections) {
620
+ if (conn.nodes.includes(current)) {
621
+ const neighbor = conn.nodes.find((n) => n !== current)!;
622
+ if (citySet.has(neighbor) && !visited.has(neighbor)) {
623
+ stack.push(neighbor);
624
+ }
625
+ }
626
+ }
627
+ }
628
+ }
629
+ return count;
630
+ }
631
+
572
632
  function dijkstra(G: GameState, player: Player): { name: string; price: number }[] {
573
633
  const nodes = G.map.cities.map((c) => ({
574
634
  name: c.name,
@@ -141,6 +141,50 @@ describe('Engine', () => {
141
141
  expect(ended(G)).to.be.false;
142
142
  });
143
143
 
144
+ it('should setup Korea with dual-market populated and players ready to move', () => {
145
+ // Smoke test for the Korea map (PR #74). If a deployed lobby fails to
146
+ // auto-start, setup() is the most likely culprit — this catches that
147
+ // class of bug without needing a recorded game fixture.
148
+ const G = setup(
149
+ 2,
150
+ { map: 'Korea', variant: 'recharged', randomizeMap: false, fastBid: false },
151
+ 'korea-test-seed'
152
+ );
153
+
154
+ expect(ended(G)).to.be.false;
155
+ expect(G.map.name).to.equal('Korea');
156
+
157
+ // South-side markets (standard fields, uranium only here)
158
+ expect(G.coalMarket).to.be.greaterThan(0);
159
+ expect(G.oilMarket).to.be.greaterThan(0);
160
+ expect(G.uraniumMarket).to.be.greaterThan(0);
161
+
162
+ // North-side markets (Korea-specific, no uranium row)
163
+ expect(G.coalMarketNorth).to.be.greaterThan(0);
164
+ expect(G.oilMarketNorth).to.be.greaterThan(0);
165
+ expect(G.garbageMarketNorth).to.exist;
166
+
167
+ // Korea uses parallel per-side price tables
168
+ expect(G.coalPricesNorth).to.be.an('array').that.is.not.empty;
169
+ expect(G.oilPricesNorth).to.be.an('array').that.is.not.empty;
170
+
171
+ // At least one player should have available moves (auction phase is live)
172
+ expect(G.players.some((p) => p.availableMoves && Object.keys(p.availableMoves).length > 0)).to.be.true;
173
+ });
174
+
175
+ it('should setup Northern Europe and have players ready to move', () => {
176
+ // Smoke test for Northern Europe (PR #74) — parity with Korea test.
177
+ const G = setup(
178
+ 2,
179
+ { map: 'Northern Europe', variant: 'recharged', randomizeMap: false, fastBid: false },
180
+ 'ne-test-seed'
181
+ );
182
+
183
+ expect(ended(G)).to.be.false;
184
+ expect(G.map.name).to.equal('Northern Europe');
185
+ expect(G.players.some((p) => p.availableMoves && Object.keys(p.availableMoves).length > 0)).to.be.true;
186
+ });
187
+
144
188
  it('should place UK & Ireland Step 3 card third from last with two plants below it', () => {
145
189
  // UK & Ireland rules: the Step 3 card (plant 99) goes at deck.length - 3
146
190
  // so two plants sit below it, and Step 3 fires two auctions earlier than
package/src/engine.ts CHANGED
@@ -95,9 +95,12 @@ export function setup(
95
95
  seed = seed ?? Math.random().toString();
96
96
  const rng = seedrandom(seed);
97
97
 
98
- const chosenMap = cloneDeep(
99
- variant == 'original' ? maps.find((m) => m.name == map)! : mapsRecharged.find((m) => m.name == map)!
100
- );
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);
101
104
 
102
105
  let actualMarket: PowerPlant[];
103
106
  let futureMarket: PowerPlant[];
@@ -625,7 +628,7 @@ export function move(G: GameState, move: Move, playerNumber: number, isUndo = fa
625
628
  G.map.name != 'Russia'
626
629
  ) {
627
630
  if (G.map.name == 'Baden-Württemberg') {
628
- // Baden-Württemberg: remove the two lowest plants
631
+ // Baden-Württemberg: remove the two lowest plants when no one buys.
629
632
  const removed: number[] = [];
630
633
  for (let i = 0; i < 2 && G.actualMarket.length > 0; i++) {
631
634
  removed.push(G.actualMarket[0].number);
package/src/gamestate.ts CHANGED
@@ -23,9 +23,9 @@ export type MapName =
23
23
  | 'Europe'
24
24
  | 'North America'
25
25
  | 'South Africa'
26
- | 'UK & Ireland';
26
+ | 'UK & Ireland'
27
+ | 'Japan';
27
28
  // | 'Australia'
28
- // | 'Japan'
29
29
  export type Variant = 'original' | 'recharged';
30
30
 
31
31
  export interface GameOptions {
@@ -55,8 +55,24 @@ export const map: GameMap = {
55
55
  { name: Cities.Sigmarincen, region: Regions.Purple, x: 1111, y: 1803 },
56
56
  { name: Cities.Konstanz, region: Regions.Purple, x: 1088, y: 2163 },
57
57
  { name: Cities.Ulm, region: Regions.Purple, x: 1541, y: 1499 },
58
- { name: Cities.Augsburg, region: Regions.Purple, x: 1830, y: 1505, transregional: true },
59
- { name: Cities.Basel, region: Regions.Green, x: 103, y: 2271, transregional: true },
58
+ {
59
+ name: Cities.Augsburg,
60
+ region: Regions.Purple,
61
+ x: 1830,
62
+ y: 1505,
63
+ transregional: true,
64
+ slotCosts: [15, 20],
65
+ stepSlots: [0, 1, 2],
66
+ },
67
+ {
68
+ name: Cities.Basel,
69
+ region: Regions.Green,
70
+ x: 103,
71
+ y: 2271,
72
+ transregional: true,
73
+ slotCosts: [15, 20],
74
+ stepSlots: [0, 1, 2],
75
+ },
60
76
  { name: Cities.Waldshuttiencen, region: Regions.Green, x: 502, y: 2166 },
61
77
  { name: Cities.Singen, region: Regions.Green, x: 874, y: 2084 },
62
78
  { name: Cities.Tuttlincen, region: Regions.Green, x: 870, y: 1854 },
@@ -69,8 +85,24 @@ export const map: GameMap = {
69
85
  { name: Cities.Sinsheim, region: Regions.Red, x: 920, y: 785 },
70
86
  { name: Cities.Heidelber, region: Regions.Red, x: 817, y: 660 },
71
87
  { name: Cities.Mannheim, region: Regions.Red, x: 710, y: 540 },
72
- { name: Cities.Luowigshafen, region: Regions.Red, x: 579, y: 588, transregional: true },
73
- { name: Cities.Nurnberg, region: Regions.Blue, x: 1837, y: 771, transregional: true },
88
+ {
89
+ name: Cities.Luowigshafen,
90
+ region: Regions.Red,
91
+ x: 579,
92
+ y: 588,
93
+ transregional: true,
94
+ slotCosts: [15, 20],
95
+ stepSlots: [0, 1, 2],
96
+ },
97
+ {
98
+ name: Cities.Nurnberg,
99
+ region: Regions.Blue,
100
+ x: 1837,
101
+ y: 771,
102
+ transregional: true,
103
+ slotCosts: [15, 20],
104
+ stepSlots: [0, 1, 2],
105
+ },
74
106
  { name: Cities.Ellwangen, region: Regions.Blue, x: 1637, y: 1030 },
75
107
  { name: Cities.Coppingen, region: Regions.Blue, x: 1387, y: 1233 },
76
108
  { name: Cities.Reutlincen, region: Regions.Blue, x: 1107, y: 1443 },
@@ -80,7 +112,15 @@ export const map: GameMap = {
80
112
  { name: Cities.Laha, region: Regions.Yellow, x: 296, y: 1575 },
81
113
  { name: Cities.Badenbaden, region: Regions.Yellow, x: 554, y: 1259 },
82
114
  { name: Cities.Offenburg, region: Regions.Yellow, x: 395, y: 1422 },
83
- { name: Cities.Strasbourg, region: Regions.Yellow, x: 211, y: 1330, transregional: true },
115
+ {
116
+ name: Cities.Strasbourg,
117
+ region: Regions.Yellow,
118
+ x: 211,
119
+ y: 1330,
120
+ transregional: true,
121
+ slotCosts: [15, 20],
122
+ stepSlots: [0, 1, 2],
123
+ },
84
124
  { name: Cities.Pforzheim, region: Regions.Yellow, x: 794, y: 1095 },
85
125
  { name: Cities.Rastatt, region: Regions.Yellow, x: 509, y: 1109 },
86
126
  { name: Cities.Karlsruhf, region: Regions.Yellow, x: 638, y: 974 },
package/src/maps/japan.ts CHANGED
@@ -49,42 +49,95 @@ export enum Cities {
49
49
  export const map: GameMap = {
50
50
  name: 'Japan',
51
51
  cities: [
52
- { name: Cities.Asahikawa, region: Regions.Brown, x: 3384, y: 414 },
53
- { name: Cities.Sapporo, region: Regions.Brown, x: 3028, y: 382 },
54
- { name: Cities.Hakodate, region: Regions.Brown, x: 2622, y: 512 },
52
+ { name: Cities.Asahikawa, region: Regions.Brown, x: 3384, y: 1014 },
53
+ { name: Cities.Sapporo, region: Regions.Brown, x: 3028, y: 982, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
54
+ { name: Cities.Hakodate, region: Regions.Brown, x: 2622, y: 1112 },
55
55
  { name: Cities.Admori, region: Regions.Brown, x: 3818, y: 1336 },
56
- { name: Cities.Morioka, region: Regions.Brown, x: 3636, y: 1646 },
56
+ { name: Cities.Morioka, region: Regions.Brown, x: 3636, y: 1646, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
57
57
  { name: Cities.Akita, region: Regions.Brown, x: 3472, y: 1480 },
58
58
  { name: Cities.Sendai, region: Regions.Brown, x: 3408, y: 1886 },
59
59
  { name: Cities.Niigata, region: Regions.Green, x: 2874, y: 1628 },
60
- { name: Cities.Koriyama, region: Regions.Green, x: 3086, y: 1932 },
60
+ { name: Cities.Koriyama, region: Regions.Green, x: 3086, y: 1932, slotCosts: [10, 15] },
61
61
  { name: Cities.Nagano, region: Regions.Green, x: 2486, y: 1722 },
62
- { name: Cities.Saitama, region: Regions.Green, x: 2450, y: 1986 },
63
- { name: Cities.Tokyo, region: Regions.Green, x: 2524, y: 2122 },
62
+ { name: Cities.Saitama, region: Regions.Green, x: 2450, y: 1986, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
63
+ { name: Cities.Tokyo, region: Regions.Green, x: 2524, y: 2122, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
64
64
  { name: Cities.Chiba, region: Regions.Green, x: 2650, y: 2316 },
65
- { name: Cities.Yokohama, region: Regions.Green, x: 2306, y: 2244 },
65
+ {
66
+ name: Cities.Yokohama,
67
+ region: Regions.Green,
68
+ x: 2306,
69
+ y: 2244,
70
+ slotCosts: [10, 10, 20],
71
+ stepSlots: [2, 2, 3],
72
+ },
66
73
  { name: Cities.Kanazawa, region: Regions.Purple, x: 2207, y: 1385 },
67
- { name: Cities.Toyama, region: Regions.Purple, x: 2257, y: 1595 },
74
+ { name: Cities.Toyama, region: Regions.Purple, x: 2257, y: 1595, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
68
75
  { name: Cities.Kyoto, region: Regions.Purple, x: 1765, y: 1595 },
69
- { name: Cities.Osaka, region: Regions.Purple, x: 1517, y: 1631 },
76
+ { name: Cities.Osaka, region: Regions.Purple, x: 1517, y: 1631, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
70
77
  { name: Cities.Nagoya, region: Regions.Purple, x: 1942, y: 1751 },
71
78
  { name: Cities.Kofu, region: Regions.Purple, x: 2179, y: 1897 },
72
- { name: Cities.Hamamatsu, region: Regions.Purple, x: 1958, y: 2024 },
73
- { name: Cities.Kobe, region: Regions.Yellow, x: 1555, y: 1451 },
74
- { name: Cities.Matsue, region: Regions.Yellow, x: 1457, y: 1006 },
79
+ { name: Cities.Hamamatsu, region: Regions.Purple, x: 1958, y: 2024, slotCosts: [10, 15] },
80
+ { name: Cities.Kobe, region: Regions.Yellow, x: 1555, y: 1451, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
81
+ { name: Cities.Matsue, region: Regions.Yellow, x: 1457, y: 1006, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
75
82
  { name: Cities.Okayama, region: Regions.Yellow, x: 1390, y: 1286 },
76
83
  { name: Cities.Takamatsu, region: Regions.Yellow, x: 1268, y: 1507 },
77
- { name: Cities.Kochi, region: Regions.Yellow, x: 974, y: 1472 },
84
+ { name: Cities.Kochi, region: Regions.Yellow, x: 974, y: 1472, slotCosts: [10, 15] },
78
85
  { name: Cities.Matsuyama, region: Regions.Yellow, x: 1036, y: 1268 },
79
86
  { name: Cities.Hiroshima, region: Regions.Yellow, x: 1103, y: 1078 },
80
87
  { name: Cities.Shimonoseki, region: Regions.Red, x: 856, y: 962 },
81
- { name: Cities.Oita, region: Regions.Red, x: 731, y: 1166 },
82
- { name: Cities.Miyazaki, region: Regions.Red, x: 469, y: 1318 },
88
+ { name: Cities.Oita, region: Regions.Red, x: 731, y: 1166, slotCosts: [10, 15] },
89
+ { name: Cities.Miyazaki, region: Regions.Red, x: 469, y: 1318, slotCosts: [15, 20], stepSlots: [0, 2, 2] },
83
90
  { name: Cities.Kagoshima, region: Regions.Red, x: 157, y: 1255 },
84
91
  { name: Cities.Kumamoto, region: Regions.Red, x: 457, y: 1045 },
85
- { name: Cities.Fukuoka, region: Regions.Red, x: 644, y: 823 },
92
+ { name: Cities.Fukuoka, region: Regions.Red, x: 644, y: 823, slotCosts: [10, 10, 20], stepSlots: [2, 2, 3] },
86
93
  { name: Cities.Nagasaki, region: Regions.Red, x: 313, y: 875 },
87
94
  ],
95
+ layout: 'Portrait',
96
+ adjustRatio: [0.28, 0.28],
97
+ mapPosition: [0, 120],
98
+ resupply: [
99
+ // Coal
100
+ [
101
+ [3, 4, 3], // 2P
102
+ [4, 5, 3], // 3P
103
+ [5, 6, 4], // 4P
104
+ [5, 7, 5], // 5P
105
+ [7, 9, 6], // 6P
106
+ ],
107
+ // Oil
108
+ [
109
+ [2, 2, 4],
110
+ [2, 3, 4],
111
+ [3, 4, 5],
112
+ [4, 5, 6],
113
+ [5, 6, 7],
114
+ ],
115
+ // Garbage
116
+ [
117
+ [1, 2, 3],
118
+ [1, 2, 3],
119
+ [2, 3, 4],
120
+ [3, 3, 5],
121
+ [3, 5, 6],
122
+ ],
123
+ // Uranium
124
+ [
125
+ [1, 1, 1],
126
+ [1, 1, 1],
127
+ [1, 2, 2],
128
+ [2, 3, 2],
129
+ [2, 3, 3],
130
+ ],
131
+ ],
132
+ startingResources: [21, 15, 9, 3],
133
+ startingCities: ['Fukuoka', 'Kobe', 'Osaka', 'Sapporo', 'Tokyo', 'Yokohama'],
134
+ mapSpecificRules:
135
+ 'Each player can have two separate networks.\n' +
136
+ 'First houses must be placed in one of six starting cities: Fukuoka, Kobe, Osaka, Sapporo, Tokyo, or Yokohama.\n' +
137
+ 'Starting cities have two first-connection spots (cost 10 Elektro each); two players can build there in Step 1.\n' +
138
+ 'In Step 3, a third connection spot opens in starting cities (cost 20 Elektro).\n' +
139
+ 'Some smaller cities have only two building spots (costs 10 and 15, or 15 and 20 from Step 2).\n' +
140
+ 'If all spots in every starting city are taken, a player is forced to play with a single network.',
88
141
  connections: [
89
142
  { nodes: [Cities.Nagasaki, Cities.Fukuoka], cost: 10 },
90
143
  { nodes: [Cities.Fukuoka, Cities.Shimonoseki], cost: 10 },
package/src/maps.ts CHANGED
@@ -12,7 +12,7 @@ import { map as france } from './maps/france';
12
12
  import { map as germany, mapRecharged as germanyRecharged } from './maps/germany';
13
13
  import { map as indian } from './maps/indian';
14
14
  import { map as italy } from './maps/italy';
15
- // import { map as japan } from './maps/japan';
15
+ import { map as japan } from './maps/japan';
16
16
  import { map as korea } from './maps/korea';
17
17
  import { map as middleeast } from './maps/middleeast';
18
18
  import { map as northamerica } from './maps/northamerica';
@@ -32,6 +32,12 @@ export interface City {
32
32
  // and connecting to them costs a fixed price (transregionalConnectionCost on the GameMap)
33
33
  // instead of the normal dijkstra path cost.
34
34
  transregional?: boolean;
35
+ // Custom per-slot building costs (e.g. Japan). Length also determines total slots.
36
+ // Defaults to [10, 15, 20] if omitted.
37
+ slotCosts?: number[];
38
+ // Max slots open per step [step1, step2, step3].
39
+ // Defaults to [1, 2, 3] (standard Power Grid rules) if omitted.
40
+ stepSlots?: number[];
35
41
  // South Africa's six cross-border foreign-country spaces: only one player ever
36
42
  // builds here (cap 1 instead of the standard 3), and the build skips the
37
43
  // 10+position*5 house-base cost — the dijkstra path cost (the 30-Elektro edge)
@@ -106,6 +112,10 @@ export interface GameMap {
106
112
  // cost (there is no sea edge) and pay 10+position*5 + this surcharge.
107
113
  crossIslandSurcharge?: number;
108
114
  mapSpecificRules?: string;
115
+ // Cities where a player's first house (or second network) must be placed.
116
+ // Used by Japan: only the 6 designated starting cities are valid first builds,
117
+ // and a second disconnected network can only begin in one of these cities.
118
+ startingCities?: string[];
109
119
  // Dev-only: when set, the viewer renders an `<image>` backdrop behind the
110
120
  // map and logs click positions (in local SVG coords) to the console as
111
121
  // ready-to-paste `{ name, region, x, y }` lines. Intended for authoring
@@ -137,7 +147,7 @@ export const maps: GameMap[] = [
137
147
  southafrica,
138
148
  ukireland,
139
149
  // australia,
140
- // japan,
150
+ japan,
141
151
  ];
142
152
 
143
153
  export const mapsRecharged: GameMap[] = [