powergrid-viewer 2.0.3 → 2.0.5

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": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -143,7 +143,23 @@
143
143
  </g>
144
144
 
145
145
  <g ref="slotResources" :transform="slotT('resources')">
146
+ <!-- On a phone the printed price track is a strip of unreadable
147
+ columns, so the stacked layout swaps it for one box per buyable
148
+ source. Landscape and desktop keep the printed board exactly as
149
+ it was — this is an either/or, never an overlay. -->
150
+ <ResourceBoxes
151
+ v-if="stacked"
152
+ :transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
153
+ :gameState="G"
154
+ :player="player"
155
+ :isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
156
+ :buyableResources="buyableResources()"
157
+ :resourceResupply="getResourceResupply()"
158
+ :resourceResupplyNorth="getResourceResupplyNorth()"
159
+ @buyResource="buyResource($event)"
160
+ />
146
161
  <Resources
162
+ v-else
147
163
  ref="resources"
148
164
  :transform="`translate(${G.map.supplyPosition[0]}, ${G.map.supplyPosition[1]})`"
149
165
  :isUsaRecharged="G.options.variant == 'recharged' && G.map.name == 'USA'"
@@ -162,12 +178,76 @@
162
178
  />
163
179
  </g>
164
180
 
181
+ <!-- Australia: the same uranium-mine selling table laid on its side for
182
+ portrait. A 104x430 strip is the worst possible shape for a full-width
183
+ row — it can only grow until its HEIGHT fills the row, so it ends up a
184
+ sliver with the whole width beside it empty. Turned through 90° the six
185
+ prices run left to right in the same order they run top to bottom, and
186
+ the row is filled by something worth reading. -->
187
+ <g
188
+ v-if="stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
189
+ ref="slotUraniumMines"
190
+ :transform="slotT('uraniumMines') || 'translate(15, 70)'"
191
+ >
192
+ <rect x="0" y="0" width="620" height="146" rx="6" fill="#8aa84a" stroke="#4d6322" stroke-width="3" />
193
+ <text x="310" y="26" text-anchor="middle" font-weight="700" fill="black" style="font-size: 22px">
194
+ Uranium mine market
195
+ </text>
196
+ <g v-for="col in 6" :key="'uraniumCol' + col" :transform="`translate(${10 + (col - 1) * 100}, 0)`">
197
+ <text x="50" y="56" text-anchor="middle" font-weight="700" fill="black" style="font-size: 20px">
198
+ ${{ 8 - col }}
199
+ </text>
200
+ <rect
201
+ x="21"
202
+ y="66"
203
+ width="26"
204
+ height="26"
205
+ rx="3"
206
+ fill="goldenrod"
207
+ stroke="#4d6322"
208
+ stroke-width="1.5"
209
+ />
210
+ <rect
211
+ x="53"
212
+ y="66"
213
+ width="26"
214
+ height="26"
215
+ rx="3"
216
+ fill="goldenrod"
217
+ stroke="#4d6322"
218
+ stroke-width="1.5"
219
+ />
220
+ <circle
221
+ v-if="(G.uraniumMineMarket[6 - col] || 0) >= 1"
222
+ cx="34"
223
+ cy="79"
224
+ r="10"
225
+ fill="#46c655"
226
+ stroke="#1f5c25"
227
+ stroke-width="2"
228
+ />
229
+ <circle
230
+ v-if="(G.uraniumMineMarket[6 - col] || 0) >= 2"
231
+ cx="66"
232
+ cy="79"
233
+ r="10"
234
+ fill="#46c655"
235
+ stroke="#1f5c25"
236
+ stroke-width="2"
237
+ />
238
+ </g>
239
+ <line x1="10" y1="108" x2="610" y2="108" stroke="#4d6322" stroke-width="1" />
240
+ <text x="310" y="132" text-anchor="middle" font-weight="700" fill="#22340f" style="font-size: 18px">
241
+ refill −{{ G.map.uraniumMineResupply[G.players.length - 2][G.step - 1] }}/rnd
242
+ </text>
243
+ </g>
244
+
165
245
  <!-- Australia: uranium-mine selling table. Six price rows $7 (top) →
166
246
  $2 (bottom), two token slots each. Sellers place one token per mine
167
247
  on the highest empty slot; the resource refill removes from the
168
248
  cheap (bottom) end. Lives in the clear upper-left margin. -->
169
249
  <g
170
- v-if="G.map.name === 'Australia' && G.uraniumMineMarket"
250
+ v-if="!stacked && G.map.name === 'Australia' && G.uraniumMineMarket"
171
251
  ref="slotUraniumMines"
172
252
  :transform="slotT('uraniumMines') || 'translate(15, 70)'"
173
253
  >
@@ -279,9 +359,9 @@
279
359
  @click="undo()"
280
360
  />
281
361
  <LogButton transform="translate(15, 97)" @click="showLog()" />
282
- <SoundButton transform="translate(110, 13)" :isOn="preferences.sound" @click="toggleSound()" />
283
- <HelpButton transform="translate(110, 54)" :isOn="!preferences.disableHelp" @click="toggleHelp()" />
284
- <RulesButton transform="translate(110, 95)" @click="rulesVisible = true" />
362
+ <SoundButton :transform="iconButton(0)" :isOn="preferences.sound" @click="toggleSound()" />
363
+ <HelpButton :transform="iconButton(1)" :isOn="!preferences.disableHelp" @click="toggleHelp()" />
364
+ <RulesButton :transform="iconButton(2)" @click="rulesVisible = true" />
285
365
  <!-- Only offered where it means something. Shown whenever the
286
366
  viewport is portrait — not only while stacking is active — so
287
367
  it can undo its own effect. Sits under Rules rather than under
@@ -289,7 +369,7 @@
289
369
  on the authored board. -->
290
370
  <LayoutButton
291
371
  v-if="portraitViewport"
292
- transform="translate(110, 136)"
372
+ :transform="iconButton(3)"
293
373
  :isOn="stacked"
294
374
  @click="toggleStackLayout()"
295
375
  />
@@ -310,7 +390,7 @@
310
390
  :ended="gameEnded(G)"
311
391
  :isPlayer="player == playerIndex"
312
392
  :ranking="sortedPlayers.findIndex((x) => x.id == G.players[playerIndex].id) + 1"
313
- :showMoney="player == playerIndex || gameEnded(G) || G.options.showMoney"
393
+ :showMoney="player == playerIndex || moneyIsPublic"
314
394
  :showBid="!G.options.fastBid"
315
395
  :phase="G.phase"
316
396
  :isAustralia="G.map.name === 'Australia'"
@@ -660,6 +740,7 @@ import PowerPlantMarket from './boards/PowerPlantMarket.vue';
660
740
  import PlayerOrder from './boards/PlayerOrder.vue';
661
741
  import CityCount from './boards/CityCount.vue';
662
742
  import Map from './boards/Map.vue';
743
+ import ResourceBoxes from './boards/ResourceBoxes.vue';
663
744
  import Resources from './boards/Resources.vue';
664
745
  import { LogMove } from 'powergrid-engine/src/log';
665
746
  import { Phase, playerTimeUsed, PowerPlant, PowerPlantType, ResourceType } from 'powergrid-engine/src/gamestate';
@@ -703,7 +784,7 @@ const STACK_MAX_ROW_SCREENS = 0.72;
703
784
  * readable; the buttons next to it are tap targets and want every pixel.
704
785
  */
705
786
  const STACK_SLOT_MAX_SCALE: Record<string, number> = {
706
- roundInfo: 2.2,
787
+ roundInfo: 1.9,
707
788
  playerOrder: 2.5,
708
789
  };
709
790
  /**
@@ -719,6 +800,9 @@ const STACK_SLOT_MAX_SCALE: Record<string, number> = {
719
800
  */
720
801
  const STACK_SLOT_MAX_WIDTH: Record<string, number> = {
721
802
  powerPlantMarket: 0.88,
803
+ // The box market is one tall stack of full-width buttons; run edge to edge it
804
+ // reads as though it had been cropped rather than laid out.
805
+ resources: 0.94,
722
806
  };
723
807
 
724
808
  /**
@@ -794,7 +878,8 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
794
878
  PlayerOrder,
795
879
  CityCount,
796
880
  Map,
797
- Resources
881
+ Resources,
882
+ ResourceBoxes
798
883
  },
799
884
  })
800
885
  export default class Game extends Vue {
@@ -822,6 +907,11 @@ export default class Game extends Vue {
822
907
 
823
908
  paused = false;
824
909
 
910
+ // Set once the platform has served a finished game, and never cleared.
911
+ // Reactive on purpose: `_futureState` is not, and the money gate has to
912
+ // re-evaluate as the replay scrubber steps through the game.
913
+ gameIsOver = false;
914
+
825
915
  @Provide()
826
916
  communicator: EventEmitter = new EventEmitter();
827
917
 
@@ -944,11 +1034,19 @@ export default class Game extends Vue {
944
1034
  return state;
945
1035
  }
946
1036
 
947
- replaceState(state: GameState, replaceState = true) {
1037
+ replaceState(state: GameState, replaceState = true, playSound = true) {
948
1038
  if (replaceState) {
949
1039
  this._futureState = state;
950
1040
  }
951
1041
 
1042
+ // Remember that the platform has served us a finished game. The replay
1043
+ // scrubber renders reconstructed MID-game states, whose own `ended()` is
1044
+ // false however long ago the game finished, so it cannot be asked. See
1045
+ // `moneyIsPublic`.
1046
+ if (replaceState && ended(state)) {
1047
+ this.gameIsOver = true;
1048
+ }
1049
+
952
1050
  // if player is selecting resources, keep the state
953
1051
  let player: any;
954
1052
  if (this.player != null) {
@@ -974,7 +1072,8 @@ export default class Game extends Vue {
974
1072
  this.playerOrder.createPieces(this.G!);
975
1073
  this.cityCount.createPieces(this.G!);
976
1074
  this.map.createPieces(this.G!);
977
- this.resources.createPieces(this.G!);
1075
+ // Absent while the portrait layout is showing the box market instead.
1076
+ this.resources?.createPieces(this.G!);
978
1077
  // Pieces are added imperatively, so the groups only reach their
979
1078
  // final size here — the portrait layout must measure after this,
980
1079
  // not on the state change that triggered it.
@@ -982,7 +1081,7 @@ export default class Game extends Vue {
982
1081
  });
983
1082
  }
984
1083
 
985
- if (this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
1084
+ if (playSound && this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
986
1085
  const move = (this.G?.log[this.G?.log.length - 1] as LogMove).move;
987
1086
  if (move.name == MoveName.Pass && this.G.currentPlayers.includes(this.player!)) {
988
1087
  (document.getElementById('notification')!.cloneNode(true) as HTMLAudioElement).play();
@@ -1385,15 +1484,69 @@ export default class Game extends Vue {
1385
1484
  // replays (and the eventual committed log) reproduce the same times.
1386
1485
  this.turnMoves.push({ ...move, time: Date.now() });
1387
1486
 
1487
+ // Preview the move locally BEFORE sending it. Without this the board — and with
1488
+ // it `availableMoves` — stays frozen on the last server reply, so every click
1489
+ // made while a response is in flight is judged against a stale list. Buy one
1490
+ // resource past what your plants can hold and the platform rejects the whole
1491
+ // replayed buffer with "Wrong argument for the command BuyResource"; the
1492
+ // rejected move then stays in the buffer, so every later action resends it and
1493
+ // fails again until the page is refreshed (#131).
1494
+ //
1495
+ // Replaying the buffer on the committed state is exact: any move with hidden
1496
+ // side effects commits, which ends the buffer (see util/turn-buffer).
1497
+ let preview: GameState | null = null;
1498
+
1499
+ if (this.committedState && this.player != undefined) {
1500
+ const buffered = this.turnMoves.length;
1501
+ preview = this.replayTurnBuffer();
1502
+
1503
+ if (this.turnMoves.length < buffered) {
1504
+ // The engine refused the move we just added, so it never reaches the
1505
+ // server and the board already shows the truth.
1506
+ return;
1507
+ }
1508
+ }
1509
+
1388
1510
  // Send the WHOLE turn so far: the platform is stateless between calls and
1389
1511
  // replays the buffer from the last committed (saved) state.
1390
1512
  this.emitter.emit('move', [...this.turnMoves]);
1513
+
1514
+ // Only adopt a preview that is still TENTATIVE. A committing move (Pass, a bid
1515
+ // that ends an auction) replays hidden outcomes — deck draws, upkeep — on a
1516
+ // state whose deck and seed are stripped, so its preview would flash bogus
1517
+ // results; wait for the server's committed answer instead. The echo of this
1518
+ // same buffer lands next and is authoritative, and it owns the sound, so the
1519
+ // preview stays silent rather than doubling it.
1520
+ if (preview && preview.newTurn === false) {
1521
+ this.replaceState(preview, false, false);
1522
+ }
1391
1523
  }
1392
1524
 
1393
1525
  gameEnded(G: GameState) {
1394
1526
  return ended(G);
1395
1527
  }
1396
1528
 
1529
+ /**
1530
+ * Is every player's cash public?
1531
+ *
1532
+ * Money is hidden information while a game is running, but a finished game has
1533
+ * nothing left to hide — the engine's `stripSecret` reveals it and the end-game
1534
+ * table prints it. The replay scrubber, though, shows a RECONSTRUCTED mid-game
1535
+ * state, and `ended()` on that state is false however long ago the game actually
1536
+ * finished. So stepping back through a finished game blacked out everyone
1537
+ * else's cash, which is most of what makes a replay worth reviewing: you could
1538
+ * not tell whether someone ending the game early would have changed the result
1539
+ * (eric-hu, #130). Ask whether the GAME is over, not whether the step being
1540
+ * displayed is.
1541
+ *
1542
+ * An in-progress game stays hidden, which is the point — the reconstruction
1543
+ * derives every player's true cash from the log, so this gate is the only thing
1544
+ * standing between a mid-game replay and other people's wallets.
1545
+ */
1546
+ get moneyIsPublic(): boolean {
1547
+ return !!this.G && (!!this.G.options.showMoney || this.gameIsOver || ended(this.G));
1548
+ }
1549
+
1397
1550
  canMove() {
1398
1551
  return (
1399
1552
  this.player != undefined &&
@@ -1836,6 +1989,19 @@ export default class Game extends Vue {
1836
1989
  return this.slotTransforms[name];
1837
1990
  }
1838
1991
 
1992
+ /**
1993
+ * Placement of the icon column beside Pass / Undo / Log. On a portrait viewport
1994
+ * that column gains a fourth button — the layout toggle — in a space authored for
1995
+ * three, and at the authored pitch the fourth one hung 41 units below everything
1996
+ * else and crowded the market row beneath it. On portrait the icons shrink to the
1997
+ * 26-unit height of the text buttons next to them and re-pitch so all four end
1998
+ * level with Log. Landscape and desktop keep the authored positions exactly.
1999
+ */
2000
+ iconButton(index: number): string {
2001
+ if (!this.portraitViewport) return `translate(110, ${13 + 41 * index})`;
2002
+ return `translate(110, ${13 + 30 * index}) scale(${round(26 / 30, 4)})`;
2003
+ }
2004
+
1839
2005
  /**
1840
2006
  * Only portrait viewports are re-laid out. A landscape phone already reads
1841
2007
  * well (the scene's own aspect ratio is close to the screen's), so leaving
@@ -1900,18 +2066,40 @@ export default class Game extends Vue {
1900
2066
  const gaps = STACK_GAP * (present.length - 1);
1901
2067
  const usable = STACK_WIDTH - 2 * STACK_PAD - gaps;
1902
2068
  const combined = present.reduce((sum, name) => sum + boxes[name].width, 0);
1903
- // One scale per row keeps neighbours visually consistent. The cap stops
1904
- // a small strip (the turn-order token row) from being blown up absurdly.
1905
- const rowScale = Math.min(usable / combined, STACK_MAX_SCALE);
1906
- // A slot may decline part of that scale so a neighbour keeps the space,
1907
- // and no slot may grow past the height budget for one row.
1908
- const scaleOf = (name: string) =>
2069
+ // A slot may decline part of the row's scale so a neighbour keeps the
2070
+ // space, and no slot may grow past the height budget for one row.
2071
+ const ceilingOf = (name: string) =>
1909
2072
  Math.min(
1910
- rowScale,
1911
2073
  STACK_SLOT_MAX_SCALE[name] ?? Infinity,
1912
2074
  ((STACK_SLOT_MAX_WIDTH[name] ?? Infinity) * usable) / boxes[name].width,
1913
- heightCapFor(boxes[name].height)
2075
+ heightCapFor(boxes[name].height),
2076
+ STACK_MAX_SCALE
1914
2077
  );
2078
+ // What a capped slot declines is width left on the table: without this it
2079
+ // became margin either side of the row. Hand it to the slots that can
2080
+ // still grow — the round readout gives up ~130 units beside the buttons,
2081
+ // and the buttons are the part a thumb needs. Repeat until it settles:
2082
+ // growing the free slots can push one of them into its own ceiling, and
2083
+ // then there is more to re-split. rowScale only ever rises here, and a
2084
+ // slot that caps mid-loop leaves the row shorter than solved for, so the
2085
+ // row can never end up wider than the space it was given.
2086
+ // One scale per row keeps neighbours visually consistent. The cap stops a
2087
+ // small strip (the turn-order token row) from being blown up absurdly.
2088
+ let rowScale = Math.min(usable / combined, STACK_MAX_SCALE);
2089
+ for (let pass = 0; pass < present.length; pass++) {
2090
+ let cappedWidth = 0;
2091
+ let freeWidth = 0;
2092
+ for (const name of present) {
2093
+ const ceiling = ceilingOf(name);
2094
+ if (ceiling < rowScale) cappedWidth += boxes[name].width * ceiling;
2095
+ else freeWidth += boxes[name].width;
2096
+ }
2097
+ if (!freeWidth) break;
2098
+ const grown = Math.min((usable - cappedWidth) / freeWidth, STACK_MAX_SCALE);
2099
+ if (grown <= rowScale + 0.0001) break;
2100
+ rowScale = grown;
2101
+ }
2102
+ const scaleOf = (name: string) => Math.min(rowScale, ceilingOf(name));
1915
2103
  const rowWidth = present.reduce((sum, name) => sum + boxes[name].width * scaleOf(name), 0);
1916
2104
  const rowHeight = Math.max(...present.map((name) => boxes[name].height * scaleOf(name)));
1917
2105
 
@@ -1933,6 +2121,19 @@ export default class Game extends Vue {
1933
2121
  this.stacked = true;
1934
2122
  }
1935
2123
 
2124
+ /**
2125
+ * Entering or leaving the stacked layout swaps the printed resource track for the
2126
+ * box market, which is a different shape entirely. `relayout` sets `stacked` last,
2127
+ * so the swap only reaches the DOM a tick later — by which point the row heights
2128
+ * it just solved for describe the component that is no longer there. Measure again
2129
+ * once the swap has landed. This settles after one extra pass: the second run
2130
+ * leaves `stacked` unchanged, so it does not re-trigger.
2131
+ */
2132
+ @Watch('stacked')
2133
+ onStackedChanged() {
2134
+ this.scheduleRelayout();
2135
+ }
2136
+
1936
2137
  private scheduleRelayout() {
1937
2138
  this.$nextTick(() => this.relayout());
1938
2139
  }
@@ -386,6 +386,55 @@
386
386
  />
387
387
  </template>
388
388
 
389
+ <!-- City tap targets, drawn LAST so they sit above the houses already built
390
+ there. Houses are painted after the city's own circle, and SVG hit-testing
391
+ takes the topmost element — so once a city held houses the active player
392
+ could only pick at the gray slivers between them (eric-hu, #125).
393
+
394
+ The radius reaches out to the printed region ring, which is what the eye
395
+ reads as the edge of the city, but never past half the distance to the
396
+ nearest city: the maps disagree enormously about spacing (Bremen's closest
397
+ pair is 90.8 apart, South Africa's Johannesburg N/S is 21), so the limit
398
+ has to be measured rather than picked.
399
+
400
+ Only rendered while the city can actually be acted on, so houses keep
401
+ their own tooltips the rest of the time. -->
402
+ <template v-for="city in cities">
403
+ <circle
404
+ v-if="city.connectionCost == null && (canBuild(city) || canPickRegion(city))"
405
+ :key="city.name + '_tap'"
406
+ class="canClick"
407
+ :r="tapRadius(city)"
408
+ :cx="city.x"
409
+ :cy="city.y"
410
+ fill="none"
411
+ pointer-events="all"
412
+ @click="onCityClick(city)"
413
+ >
414
+ <title>{{ city.name }}</title>
415
+ </circle>
416
+ <!-- Node-weighted tiles (Bremen) get the same treatment at the tile's own
417
+ size — their houses sit in slots inside the diamond. Manhattan's spaces
418
+ hold one house each, so a built space is never buildable again and needs
419
+ no overlay. -->
420
+ <rect
421
+ v-if="isNodeTile(city) && (canBuild(city) || canPickRegion(city))"
422
+ :key="city.name + '_tapTile'"
423
+ class="canClick"
424
+ :x="city.x - 23"
425
+ :y="city.y - 23"
426
+ width="46"
427
+ height="46"
428
+ rx="7"
429
+ fill="none"
430
+ pointer-events="all"
431
+ :transform="`rotate(45, ${city.x}, ${city.y})`"
432
+ @click="onCityClick(city)"
433
+ >
434
+ <title>{{ city.name }}</title>
435
+ </rect>
436
+ </template>
437
+
389
438
  <!-- Regions where nuclear plants are banned: a nuclear-plant tile under a red
390
439
  prohibition sign (ring + diagonal bar), matching the marker printed on
391
440
  the physical board. -->
@@ -421,6 +470,11 @@ import { House } from '../pieces';
421
470
  import { Piece, Preferences } from '../../types/ui-data';
422
471
  import { City, Connection, Polygon } from 'powergrid-engine/src/maps';
423
472
 
473
+ /** The gray disc a city is drawn as, and the tap target it has always had. */
474
+ const CITY_RADIUS = 20;
475
+ /** The coloured region ring drawn around that disc — the city's visible edge. */
476
+ const CITY_RING_RADIUS = 25;
477
+
424
478
  @Component({
425
479
  components: {
426
480
  House,
@@ -605,6 +659,42 @@ export default class Map extends Vue {
605
659
  return markers;
606
660
  }
607
661
 
662
+ /**
663
+ * Distance from each city to its nearest neighbour. Cached as a computed so the
664
+ * O(n^2) sweep runs once per board rather than once per tap target; n is at most
665
+ * a few dozen cities.
666
+ */
667
+ get nearestCityDistance(): Record<string, number> {
668
+ const out: Record<string, number> = {};
669
+ const cities = this.cities ?? [];
670
+ for (const a of cities) {
671
+ let min = Infinity;
672
+ for (const b of cities) {
673
+ if (b === a) continue;
674
+ const d = Math.hypot(b.x - a.x, b.y - a.y);
675
+ if (d < min) min = d;
676
+ }
677
+ out[a.name] = min;
678
+ }
679
+ return out;
680
+ }
681
+
682
+ /**
683
+ * How far a city's tap target reaches. Out to the region ring where there is room
684
+ * for it, but never more than halfway to the next city — on the tight maps two
685
+ * targets would otherwise overlap and whichever is drawn later would swallow both
686
+ * cities' taps. Never smaller than the disc it has always been.
687
+ */
688
+ tapRadius(city: City): number {
689
+ const halfWayToNeighbour = (this.nearestCityDistance[city.name] ?? Infinity) / 2;
690
+ return Math.max(CITY_RADIUS, Math.min(CITY_RING_RADIUS, halfWayToNeighbour));
691
+ }
692
+
693
+ /** A Bremen-style node-weighted district: a diamond tile with several house slots. */
694
+ isNodeTile(city: City) {
695
+ return city.connectionCost != null && !!city.slotCosts && city.slotCosts.length >= 2;
696
+ }
697
+
608
698
  canBuild(city: City) {
609
699
  return !!this.buildableCities!.find((cityName) => cityName == city.name);
610
700
  }