powergrid-viewer 2.0.13 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "powergrid-viewer",
3
- "version": "2.0.13",
3
+ "version": "2.1.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -18,7 +18,7 @@
18
18
  "vue": "^2.6.11",
19
19
  "vue-class-component": "^7.2.3",
20
20
  "vue-property-decorator": "^8.4.2",
21
- "powergrid-engine": "2.0.5"
21
+ "powergrid-engine": "2.0.6"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/assert": "^1.4.7",
@@ -3,14 +3,14 @@
3
3
  <div class="statusBar">
4
4
  {{ getStatusMessage() }}
5
5
  </div>
6
- <audio id="piece-drop" preload="none">
7
- <source src="../audio/piece-drop.mp3" type="audio/mpeg" />
8
- </audio>
9
- <audio id="notification" preload="none">
10
- <source src="../audio/notification.mp3" type="audio/mpeg" />
11
- <source src="../audio/notification.ogg" type="audio/ogg" />
12
- </audio>
13
- <svg v-if="G" id="scene" :class="{ stacked: stacked }" :viewBox="sceneViewBox" style="width: 100%">
6
+ <svg
7
+ v-if="G"
8
+ id="scene"
9
+ :class="{ stacked: stacked }"
10
+ :viewBox="sceneViewBox"
11
+ :style="{ width: '100%', aspectRatio: sceneViewBox.split(' ').slice(2).join(' / ') }"
12
+ preserveAspectRatio="xMidYMin meet"
13
+ >
14
14
  <rect width="100%" height="100%" x="0" y="0" fill="yellowgreen" />
15
15
 
16
16
  <g ref="slotPlayerOrder" :transform="slotT('playerOrder')">
@@ -98,6 +98,7 @@
98
98
  :connections="G.map.connections"
99
99
  :polygons="G.map.polygons"
100
100
  :buildableCities="getBuildableCities()"
101
+ :buildPrices="getBuildableCityPrices()"
101
102
  :blockedCities="G.blockedCities"
102
103
  :pickableRegions="getPickableRegions()"
103
104
  :noUraniumRegions="G.map.noUraniumRegions"
@@ -413,6 +414,11 @@
413
414
  </g>
414
415
  </svg>
415
416
 
417
+ <div class="journal-and-chat">
418
+ <InlineLog v-if="G" :entries="logReversed.slice().reverse()" :mapName="G.options.map" />
419
+ <div class="chat-host"></div>
420
+ </div>
421
+
416
422
  <div v-if="G" :class="['modal', { visible: logVisible }]">
417
423
  <div class="modal-content">
418
424
  <span class="close" @click="logVisible = false">&times;</span>
@@ -734,6 +740,7 @@
734
740
  </div>
735
741
  </template>
736
742
  <script lang="ts">
743
+ import InlineLog from './InlineLog.vue';
737
744
  import { Vue, Component, Prop, Watch, Provide, ProvideReactive, Ref } from 'vue-property-decorator';
738
745
  import { MoveName, ended, playersSortedByScore, reconstructState } from 'powergrid-engine';
739
746
  import type { GameState, LogItem, Move, Player } from 'powergrid-engine';
@@ -866,21 +873,21 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
866
873
  this.paused = true;
867
874
  this.emitter.emit('replay:info', {
868
875
  start: 1,
869
- current: this.G!.log.filter(l => l.type == 'move').length,
870
- end: this._futureState!.log.filter(l => l.type == 'move').length,
876
+ current: this.G!.log.filter((l) => l.type == 'move').length,
877
+ end: this._futureState!.log.filter((l) => l.type == 'move').length,
871
878
  });
872
879
  });
873
880
 
874
881
  this.emitter.on('replayTo', (to: number) => {
875
- const log = this._futureState!.log.map((l, i) => ({ index: i, ...l })).filter(l => l.type == 'move');
882
+ const log = this._futureState!.log.map((l, i) => ({ index: i, ...l })).filter((l) => l.type == 'move');
876
883
  to = log[to - 1].index;
877
884
 
878
885
  this.replaceState(reconstructState(this._futureState!, to + 1), false);
879
886
 
880
887
  this.emitter.emit('replay:info', {
881
888
  start: 1,
882
- current: this.G!.log.filter(l => l.type == 'move').length + this.G!.hiddenLog.length,
883
- end: this._futureState!.log.filter(l => l.type == 'move').length,
889
+ current: this.G!.log.filter((l) => l.type == 'move').length + this.G!.hiddenLog.length,
890
+ end: this._futureState!.log.filter((l) => l.type == 'move').length,
884
891
  });
885
892
  });
886
893
 
@@ -890,6 +897,7 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
890
897
  });
891
898
  },
892
899
  components: {
900
+ InlineLog,
893
901
  PlayerBoard,
894
902
  Card,
895
903
  House,
@@ -912,7 +920,7 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
912
920
  CityCount,
913
921
  Map,
914
922
  Resources,
915
- ResourceBoxes
923
+ ResourceBoxes,
916
924
  },
917
925
  })
918
926
  export default class Game extends Vue {
@@ -980,7 +988,7 @@ export default class Game extends Vue {
980
988
 
981
989
  discardedPowerPlant: PowerPlant | null = null;
982
990
  discardVisible: boolean = false;
983
- resourcesToDiscard: { name: string, max: number, value: string }[] = [];
991
+ resourcesToDiscard: { name: string; max: number; value: string }[] = [];
984
992
 
985
993
  freeJumpCity: City | null = null;
986
994
  freeJumpNormalPrice: number | null = null;
@@ -1095,7 +1103,7 @@ export default class Game extends Vue {
1095
1103
  player = {
1096
1104
  coalLeft: this.G.players[this.player].coalLeft,
1097
1105
  oilLeft: this.G.players[this.player].oilLeft,
1098
- resourcesUsed: this.G.players[this.player].resourcesUsed
1106
+ resourcesUsed: this.G.players[this.player].resourcesUsed,
1099
1107
  };
1100
1108
  }
1101
1109
  }
@@ -1121,19 +1129,6 @@ export default class Game extends Vue {
1121
1129
  this.scheduleRelayout();
1122
1130
  });
1123
1131
  }
1124
-
1125
- if (playSound && this.preferences.sound && this.G?.log[this.G?.log.length - 1].type == 'move') {
1126
- const move = (this.G?.log[this.G?.log.length - 1] as LogMove).move;
1127
- if (move.name == MoveName.Pass && this.G.currentPlayers.includes(this.player!)) {
1128
- (document.getElementById('notification')!.cloneNode(true) as HTMLAudioElement).play();
1129
- } else {
1130
- if (move.name == MoveName.Build) {
1131
- setTimeout(() => {
1132
- (document.getElementById('piece-drop')!.cloneNode(true) as HTMLAudioElement).play();
1133
- }, 800);
1134
- }
1135
- }
1136
- }
1137
1132
  }
1138
1133
 
1139
1134
  @Watch('ui.waitingAnimations')
@@ -1156,9 +1151,12 @@ export default class Game extends Vue {
1156
1151
  if (this.G && this.player != null && !this.G.chosenPowerPlant) {
1157
1152
  const player = this.G.players[this.player];
1158
1153
  if (player && player.availableMoves && Object.keys(player.availableMoves).length > 1) {
1159
- if (this.G.phase == Phase.Bureaucracy && player.powerPlantsNotUsed.length > 0
1160
- && Object.keys(player.availableMoves).includes('UsePowerPlant')
1161
- && !this.canPowerAllCitiesWithUsedPlants(player)) {
1154
+ if (
1155
+ this.G.phase == Phase.Bureaucracy &&
1156
+ player.powerPlantsNotUsed.length > 0 &&
1157
+ Object.keys(player.availableMoves).includes('UsePowerPlant') &&
1158
+ !this.canPowerAllCitiesWithUsedPlants(player)
1159
+ ) {
1162
1160
  this.confirmMessage = 'Are you sure you want to pass? You have unused power plants!';
1163
1161
  this.confirmVisible = true;
1164
1162
  return;
@@ -1170,7 +1168,8 @@ export default class Game extends Vue {
1170
1168
  // nothing this turn — buying some but not enough is a deliberate
1171
1169
  // choice, not a slip.
1172
1170
  if (this.G.map.name !== 'India' || !this.playerBoughtResourceThisTurn()) {
1173
- this.confirmMessage = 'Are you sure you want to skip buying resources without enough to power all your plants?';
1171
+ this.confirmMessage =
1172
+ 'Are you sure you want to skip buying resources without enough to power all your plants?';
1174
1173
  this.confirmVisible = true;
1175
1174
  return;
1176
1175
  }
@@ -1193,7 +1192,7 @@ export default class Game extends Vue {
1193
1192
  this.confirmMessage = 'Are you sure you want to skip building?';
1194
1193
  break;
1195
1194
  case Phase.Bureaucracy:
1196
- this.confirmMessage = 'Are you sure you want to pass? You didn\'t use any power plant!';
1195
+ this.confirmMessage = "Are you sure you want to pass? You didn't use any power plant!";
1197
1196
  break;
1198
1197
  default:
1199
1198
  this.confirmMessage = 'Are you sure you want to pass?';
@@ -1292,8 +1291,10 @@ export default class Game extends Vue {
1292
1291
  this.soleBuyerPlant = null;
1293
1292
  }
1294
1293
 
1295
- buyResource(payload: { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean }) {
1296
- const data: { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean } = { resource: payload.resource };
1294
+ buyResource(payload: { resource: ResourceType; side?: 'north' | 'south'; fromStorage?: boolean }) {
1295
+ const data: { resource: ResourceType; side?: 'north' | 'south'; fromStorage?: boolean } = {
1296
+ resource: payload.resource,
1297
+ };
1297
1298
  if (payload.side) {
1298
1299
  data.side = payload.side;
1299
1300
  }
@@ -1312,7 +1313,7 @@ export default class Game extends Vue {
1312
1313
  * replaying the rest, exactly as `undo()` drops the last move. Removing a buy only
1313
1314
  * ever frees money and plant capacity, so the remaining buffer always replays.
1314
1315
  */
1315
- unbuyResource(payload: { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean }) {
1316
+ unbuyResource(payload: { resource: ResourceType; side?: 'north' | 'south'; fromStorage?: boolean }) {
1316
1317
  if (this.paused || !this.committedState) {
1317
1318
  return;
1318
1319
  }
@@ -1381,7 +1382,10 @@ export default class Game extends Vue {
1381
1382
  this.freeJumpCity = null;
1382
1383
  if (useJump) {
1383
1384
  const freeJumpMove = buildMoves.find((c) => c.name === city.name && c.freeJump)!;
1384
- this.sendMove({ name: MoveName.Build, data: { name: city.name, price: freeJumpMove.price, freeJump: true } });
1385
+ this.sendMove({
1386
+ name: MoveName.Build,
1387
+ data: { name: city.name, price: freeJumpMove.price, freeJump: true },
1388
+ });
1385
1389
  } else {
1386
1390
  const normalMove = buildMoves.find((c) => c.name === city.name && !c.freeJump)!;
1387
1391
  this.sendMove({ name: MoveName.Build, data: { name: city.name, price: normalMove.price } });
@@ -1389,7 +1393,7 @@ export default class Game extends Vue {
1389
1393
  }
1390
1394
 
1391
1395
  confirmDiscard() {
1392
- const values = this.resourcesToDiscard.map(r => parseInt(r.value));
1396
+ const values = this.resourcesToDiscard.map((r) => parseInt(r.value));
1393
1397
  if (values.reduce((acc, cur) => acc + cur, 0) > 0) {
1394
1398
  this.sendMove({ name: MoveName.DiscardPowerPlant, data: this.discardedPowerPlant!.number, extra: values });
1395
1399
  } else {
@@ -1405,18 +1409,48 @@ export default class Game extends Vue {
1405
1409
  let hybridCapacityUsed;
1406
1410
  switch (this.discardedPowerPlant!.type) {
1407
1411
  case PowerPlantType.Coal:
1408
- hybridCapacityUsed = currentPlayer.hybridCapacity - this.discardedPowerPlant!.cost * 2 > 0 ? Math.max(0, currentPlayer.oilLeft - currentPlayer.oilCapacity) : 0;
1409
- return currentPlayer.coalCapacity + currentPlayer.hybridCapacity - this.discardedPowerPlant!.cost * 2 + parseInt(this.resourcesToDiscard[0].value) < currentPlayer.coalLeft + hybridCapacityUsed;
1412
+ hybridCapacityUsed =
1413
+ currentPlayer.hybridCapacity - this.discardedPowerPlant!.cost * 2 > 0
1414
+ ? Math.max(0, currentPlayer.oilLeft - currentPlayer.oilCapacity)
1415
+ : 0;
1416
+ return (
1417
+ currentPlayer.coalCapacity +
1418
+ currentPlayer.hybridCapacity -
1419
+ this.discardedPowerPlant!.cost * 2 +
1420
+ parseInt(this.resourcesToDiscard[0].value) <
1421
+ currentPlayer.coalLeft + hybridCapacityUsed
1422
+ );
1410
1423
 
1411
1424
  case PowerPlantType.Oil:
1412
- hybridCapacityUsed = currentPlayer.hybridCapacity - this.discardedPowerPlant!.cost * 2 > 0 ? Math.max(0, currentPlayer.coalLeft - currentPlayer.coalCapacity) : 0;
1413
- return currentPlayer.oilCapacity + currentPlayer.hybridCapacity - this.discardedPowerPlant!.cost * 2 + parseInt(this.resourcesToDiscard[0].value) < currentPlayer.oilLeft + hybridCapacityUsed;
1425
+ hybridCapacityUsed =
1426
+ currentPlayer.hybridCapacity - this.discardedPowerPlant!.cost * 2 > 0
1427
+ ? Math.max(0, currentPlayer.coalLeft - currentPlayer.coalCapacity)
1428
+ : 0;
1429
+ return (
1430
+ currentPlayer.oilCapacity +
1431
+ currentPlayer.hybridCapacity -
1432
+ this.discardedPowerPlant!.cost * 2 +
1433
+ parseInt(this.resourcesToDiscard[0].value) <
1434
+ currentPlayer.oilLeft + hybridCapacityUsed
1435
+ );
1414
1436
 
1415
1437
  case PowerPlantType.Garbage:
1416
- return currentPlayer.garbageCapacity - this.discardedPowerPlant!.cost * 2 - currentPlayer.garbageLeft + parseInt(this.resourcesToDiscard[0].value) < 0;
1438
+ return (
1439
+ currentPlayer.garbageCapacity -
1440
+ this.discardedPowerPlant!.cost * 2 -
1441
+ currentPlayer.garbageLeft +
1442
+ parseInt(this.resourcesToDiscard[0].value) <
1443
+ 0
1444
+ );
1417
1445
 
1418
1446
  case PowerPlantType.Uranium:
1419
- return currentPlayer.uraniumCapacity - this.discardedPowerPlant!.cost * 2 - currentPlayer.uraniumLeft + parseInt(this.resourcesToDiscard[0].value) < 0;
1447
+ return (
1448
+ currentPlayer.uraniumCapacity -
1449
+ this.discardedPowerPlant!.cost * 2 -
1450
+ currentPlayer.uraniumLeft +
1451
+ parseInt(this.resourcesToDiscard[0].value) <
1452
+ 0
1453
+ );
1420
1454
 
1421
1455
  case PowerPlantType.Hybrid:
1422
1456
  const coalDiscarded = parseInt(this.resourcesToDiscard[0].value);
@@ -1440,7 +1474,11 @@ export default class Game extends Vue {
1440
1474
 
1441
1475
  switch (powerPlant.type) {
1442
1476
  case PowerPlantType.Coal:
1443
- if (currentPlayer.powerPlants.filter(pp => pp.type == powerPlant.type).length + currentPlayer.powerPlants.filter(pp => pp.type == PowerPlantType.Hybrid).length == 1) {
1477
+ if (
1478
+ currentPlayer.powerPlants.filter((pp) => pp.type == powerPlant.type).length +
1479
+ currentPlayer.powerPlants.filter((pp) => pp.type == PowerPlantType.Hybrid).length ==
1480
+ 1
1481
+ ) {
1444
1482
  this.sendMove({ name: MoveName.DiscardPowerPlant, data: powerPlant.number });
1445
1483
  return;
1446
1484
  }
@@ -1454,7 +1492,11 @@ export default class Game extends Vue {
1454
1492
  break;
1455
1493
 
1456
1494
  case PowerPlantType.Oil:
1457
- if (currentPlayer.powerPlants.filter(pp => pp.type == powerPlant.type).length + currentPlayer.powerPlants.filter(pp => pp.type == PowerPlantType.Hybrid).length == 1) {
1495
+ if (
1496
+ currentPlayer.powerPlants.filter((pp) => pp.type == powerPlant.type).length +
1497
+ currentPlayer.powerPlants.filter((pp) => pp.type == PowerPlantType.Hybrid).length ==
1498
+ 1
1499
+ ) {
1458
1500
  this.sendMove({ name: MoveName.DiscardPowerPlant, data: powerPlant.number });
1459
1501
  return;
1460
1502
  }
@@ -1468,7 +1510,7 @@ export default class Game extends Vue {
1468
1510
  break;
1469
1511
 
1470
1512
  case PowerPlantType.Garbage:
1471
- if (currentPlayer.powerPlants.filter(pp => pp.type == powerPlant.type).length == 1) {
1513
+ if (currentPlayer.powerPlants.filter((pp) => pp.type == powerPlant.type).length == 1) {
1472
1514
  this.sendMove({ name: MoveName.DiscardPowerPlant, data: powerPlant.number });
1473
1515
  return;
1474
1516
  }
@@ -1483,7 +1525,7 @@ export default class Game extends Vue {
1483
1525
  break;
1484
1526
 
1485
1527
  case PowerPlantType.Uranium:
1486
- if (currentPlayer.powerPlants.filter(pp => pp.type == powerPlant.type).length == 1) {
1528
+ if (currentPlayer.powerPlants.filter((pp) => pp.type == powerPlant.type).length == 1) {
1487
1529
  this.sendMove({ name: MoveName.DiscardPowerPlant, data: powerPlant.number });
1488
1530
  return;
1489
1531
  }
@@ -1497,7 +1539,12 @@ export default class Game extends Vue {
1497
1539
  break;
1498
1540
 
1499
1541
  case PowerPlantType.Hybrid:
1500
- if (currentPlayer.powerPlants.filter(pp => pp.type == powerPlant.type).length + currentPlayer.powerPlants.filter(pp => pp.type == PowerPlantType.Coal).length + currentPlayer.powerPlants.filter(pp => pp.type == PowerPlantType.Oil).length == 1) {
1542
+ if (
1543
+ currentPlayer.powerPlants.filter((pp) => pp.type == powerPlant.type).length +
1544
+ currentPlayer.powerPlants.filter((pp) => pp.type == PowerPlantType.Coal).length +
1545
+ currentPlayer.powerPlants.filter((pp) => pp.type == PowerPlantType.Oil).length ==
1546
+ 1
1547
+ ) {
1501
1548
  this.sendMove({ name: MoveName.DiscardPowerPlant, data: powerPlant.number });
1502
1549
  return;
1503
1550
  }
@@ -1507,7 +1554,10 @@ export default class Game extends Vue {
1507
1554
  return;
1508
1555
  }
1509
1556
 
1510
- this.resourcesToDiscard = [{ name: 'Coal', value: '0', max: currentPlayer.coalLeft }, { name: 'Oil', value: '0', max: currentPlayer.oilLeft }];
1557
+ this.resourcesToDiscard = [
1558
+ { name: 'Coal', value: '0', max: currentPlayer.coalLeft },
1559
+ { name: 'Oil', value: '0', max: currentPlayer.oilLeft },
1560
+ ];
1511
1561
  break;
1512
1562
  }
1513
1563
 
@@ -1534,7 +1584,9 @@ export default class Game extends Vue {
1534
1584
  resourcesSpent = currentPlayer.resourcesUsed;
1535
1585
  resourcesSpent.sort();
1536
1586
  currentPlayer.resourcesUsed = [];
1537
- currentPlayer.powerPlantsNotUsed = currentPlayer.powerPlantsNotUsed.filter((x) => x != powerPlant.number);
1587
+ currentPlayer.powerPlantsNotUsed = currentPlayer.powerPlantsNotUsed.filter(
1588
+ (x) => x != powerPlant.number
1589
+ );
1538
1590
 
1539
1591
  break;
1540
1592
  }
@@ -1545,7 +1597,7 @@ export default class Game extends Vue {
1545
1597
  });
1546
1598
 
1547
1599
  this.disablePass = true;
1548
- setTimeout(() => this.disablePass = false, 1000);
1600
+ setTimeout(() => (this.disablePass = false), 1000);
1549
1601
  }
1550
1602
  }
1551
1603
 
@@ -1704,7 +1756,7 @@ export default class Game extends Vue {
1704
1756
  return !!availableMoves[MoveName.ChoosePowerPlant];
1705
1757
  }
1706
1758
 
1707
- buyableResources(): { resource: ResourceType, side?: 'north' | 'south', fromStorage?: boolean }[] {
1759
+ buyableResources(): { resource: ResourceType; side?: 'north' | 'south'; fromStorage?: boolean }[] {
1708
1760
  if (!this.canMove()) return [];
1709
1761
 
1710
1762
  const currentPlayer = this.G!.players[this.player!];
@@ -1744,7 +1796,40 @@ export default class Game extends Vue {
1744
1796
  const currentPlayer = this.G!.players[this.player!];
1745
1797
  const availableMoves = currentPlayer.availableMoves!;
1746
1798
 
1747
- return availableMoves[MoveName.Build] && availableMoves[MoveName.Build]!.map((c) => c.name) || [];
1799
+ return (availableMoves[MoveName.Build] && availableMoves[MoveName.Build]!.map((c) => c.name)) || [];
1800
+ }
1801
+
1802
+ /**
1803
+ * What each buildable city would cost this player, keyed by city name (#148).
1804
+ *
1805
+ * The total is not computed here: `available-moves` already sums the shortest-path
1806
+ * connection cost and the slot cost for the number of players ALREADY in the city
1807
+ * (10/15/20 in the standard build, per-map `slotCosts` otherwise), then drops every
1808
+ * city the player cannot afford. That per-city occupancy is exactly what #148 asks
1809
+ * about in Step 3, so the number only needed surfacing — the viewer was discarding
1810
+ * it and keeping the names.
1811
+ *
1812
+ * A map with a free jump (Japan) offers the same city twice at two prices, so both
1813
+ * are kept; a city reachable ONLY by jump has no `price`.
1814
+ */
1815
+ getBuildableCityPrices(): Record<string, { price?: number; jumpPrice?: number }> {
1816
+ if (!this.canMove()) return {};
1817
+
1818
+ const moves = this.G!.players[this.player!].availableMoves![MoveName.Build];
1819
+ if (!moves) return {};
1820
+
1821
+ const prices: Record<string, { price?: number; jumpPrice?: number }> = {};
1822
+ for (const move of moves) {
1823
+ if (!prices[move.name]) {
1824
+ prices[move.name] = {};
1825
+ }
1826
+ if (move.freeJump) {
1827
+ prices[move.name].jumpPrice = move.price;
1828
+ } else {
1829
+ prices[move.name].price = move.price;
1830
+ }
1831
+ }
1832
+ return prices;
1748
1833
  }
1749
1834
 
1750
1835
  getPickableRegions(): string[] {
@@ -1861,10 +1946,12 @@ export default class Game extends Vue {
1861
1946
  }
1862
1947
 
1863
1948
  // Check if player has enough resources, accounting for hybrid plants which can use either coal or oil
1864
- if (coalUsed > player.coalLeft ||
1949
+ if (
1950
+ coalUsed > player.coalLeft ||
1865
1951
  oilUsed > player.oilLeft ||
1866
1952
  garbageUsed > player.garbageLeft ||
1867
- uraniumUsed > player.uraniumLeft) {
1953
+ uraniumUsed > player.uraniumLeft
1954
+ ) {
1868
1955
  return false;
1869
1956
  }
1870
1957
  const remainingCoal = player.coalLeft - coalUsed;
@@ -1946,7 +2033,7 @@ export default class Game extends Vue {
1946
2033
 
1947
2034
  return 'Choose a Power Plant to start an auction.';
1948
2035
  } else if (currentPlayer.availableMoves![MoveName.Bid]) {
1949
- return 'It\'s your turn to bid!';
2036
+ return "It's your turn to bid!";
1950
2037
  } else if (currentPlayer.availableMoves![MoveName.BuyResource]) {
1951
2038
  return 'Buy resources on the market, or pass.';
1952
2039
  } else if (currentPlayer.availableMoves![MoveName.Build]) {
@@ -1963,7 +2050,7 @@ export default class Game extends Vue {
1963
2050
  return 'Choose which resources to discard.';
1964
2051
  }
1965
2052
 
1966
- return 'It\'s your turn!';
2053
+ return "It's your turn!";
1967
2054
  } else {
1968
2055
  let log = this.G.log[this.G.log.length - 1];
1969
2056
  if (log.type == 'move') {
@@ -2032,7 +2119,9 @@ export default class Game extends Vue {
2032
2119
  // the connection cost paid to reach it, averaged over cities built.
2033
2120
  label: 'Cost per City',
2034
2121
  value: (p) =>
2035
- p.cities.length ? ((p.totalSpentCities + p.totalSpentConnections) / p.cities.length).toFixed(1) : '-',
2122
+ p.cities.length
2123
+ ? ((p.totalSpentCities + p.totalSpentConnections) / p.cities.length).toFixed(1)
2124
+ : '-',
2036
2125
  },
2037
2126
  { label: 'Spending: Plants', value: (p) => p.totalSpentPlants },
2038
2127
  { label: 'Spending: Resources', value: (p) => p.totalSpentResources },
@@ -2284,8 +2373,10 @@ export default class Game extends Vue {
2284
2373
  const scale = scaleOf(name);
2285
2374
  // Centre a shorter slot against the tallest one in its row.
2286
2375
  const top = y + (rowHeight - bb.height * scale) / 2;
2287
- transforms[name] =
2288
- `translate(${round(x - bb.x * scale)}, ${round(top - bb.y * scale)}) scale(${round(scale, 4)})`;
2376
+ transforms[name] = `translate(${round(x - bb.x * scale)}, ${round(top - bb.y * scale)}) scale(${round(
2377
+ scale,
2378
+ 4
2379
+ )})`;
2289
2380
  x += bb.width * scale + STACK_GAP;
2290
2381
  }
2291
2382
  y += rowHeight + STACK_GAP;
@@ -2408,8 +2499,8 @@ ul {
2408
2499
  flex-direction: column;
2409
2500
  }
2410
2501
 
2411
- .fitToScreen {
2412
- height: 100%;
2502
+ .fitToScreen #scene {
2503
+ max-height: calc(100vh - 40px);
2413
2504
  }
2414
2505
 
2415
2506
  .statusBar {
@@ -2420,13 +2511,17 @@ ul {
2420
2511
  text-align: center;
2421
2512
  line-height: 40px;
2422
2513
  font-size: 20px;
2423
- position: fixed;
2514
+ position: sticky;
2515
+ top: 0;
2516
+ z-index: 10;
2517
+ flex-shrink: 0;
2424
2518
  }
2425
2519
 
2426
2520
  #scene {
2427
- max-height: calc(100% - 40px);
2428
- flex-grow: 1;
2429
- margin: 40px auto auto auto;
2521
+ display: block;
2522
+ height: auto;
2523
+ flex: none;
2524
+ margin: 0 auto;
2430
2525
  }
2431
2526
 
2432
2527
  // Portrait: the scene is taller than the viewport by design (each row is scaled
@@ -2439,12 +2534,14 @@ ul {
2439
2534
  #scene {
2440
2535
  max-height: none;
2441
2536
  flex-grow: 0;
2442
- margin-top: 40px;
2537
+ margin-top: 0;
2443
2538
  }
2444
2539
  }
2445
2540
 
2446
2541
  body,
2447
2542
  html {
2543
+ background: #e7ebda;
2544
+ color: #263521;
2448
2545
  height: 100%;
2449
2546
  width: 100%;
2450
2547
  margin: 0;
@@ -2717,3 +2814,24 @@ text {
2717
2814
  margin: 15px 0 0 15px;
2718
2815
  }
2719
2816
  </style>
2817
+
2818
+ <style>
2819
+ .journal-and-chat {
2820
+ width: 100%;
2821
+ display: grid;
2822
+ gap: 8px;
2823
+ margin-top: 8px;
2824
+ align-items: start;
2825
+ }
2826
+ .journal-and-chat > * {
2827
+ min-width: 0;
2828
+ }
2829
+ .journal-and-chat .bgs-game-chat {
2830
+ margin: 0;
2831
+ }
2832
+ @media (min-width: 1000px) {
2833
+ .journal-and-chat {
2834
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
2835
+ }
2836
+ }
2837
+ </style>
@@ -0,0 +1,108 @@
1
+ <template>
2
+ <details class="inline-game-log" open>
3
+ <summary>Journal</summary>
4
+ <div ref="feed" class="journal-feed" @scroll="onScroll">
5
+ <div v-for="(entry, index) in illustratedEntries" :key="index" class="journal-entry">
6
+ <template v-for="(part, i) in entry">
7
+ <svg
8
+ v-if="part.plant"
9
+ :key="i"
10
+ class="journal-plant"
11
+ viewBox="-2 -2 64 44"
12
+ role="img"
13
+ :aria-label="plantLabel(part.plant)"
14
+ >
15
+ <title>{{ plantLabel(part.plant) }}</title>
16
+ <Card :powerPlant="part.plant" />
17
+ </svg>
18
+ <span v-else :key="i" v-html="part.html" />
19
+ </template>
20
+ </div>
21
+ <div v-if="!entries.length">No actions yet.</div>
22
+ </div>
23
+ </details>
24
+ </template>
25
+ <script lang="ts">
26
+ import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
27
+ import Card from './pieces/Card.vue';
28
+ import { getPowerPlant } from 'powergrid-engine/src/engine';
29
+ import type { PowerPlant } from 'powergrid-engine/src/gamestate';
30
+ @Component({ components: { Card } })
31
+ export default class InlineLog extends Vue {
32
+ @Prop({ default: () => [] }) entries!: string[];
33
+ @Prop({ default: '' }) mapName!: string;
34
+ follow = true;
35
+ get illustratedEntries() {
36
+ return this.entries.map((entry) => {
37
+ const parts: { html?: string; plant?: PowerPlant }[] = [];
38
+ const pattern = /Power Plant\s+(?:<b>)?(\d+)(?:<\/b>)?/gi;
39
+ let offset = 0;
40
+ let match: RegExpExecArray | null;
41
+ while ((match = pattern.exec(entry))) {
42
+ const plant = getPowerPlant(Number(match[1]), this.mapName);
43
+ if (!plant) continue;
44
+ parts.push({ html: entry.slice(offset, match.index) }, { plant });
45
+ offset = match.index + match[0].length;
46
+ }
47
+ parts.push({ html: entry.slice(offset) });
48
+ return parts;
49
+ });
50
+ }
51
+ plantLabel(plant: PowerPlant) {
52
+ return `Power plant ${plant.number}: ${plant.cost ? `${plant.cost} ${plant.type}` : 'no fuel'} → ${
53
+ plant.citiesPowered
54
+ } cities`;
55
+ }
56
+ mounted() {
57
+ this.scrollToLatest();
58
+ }
59
+ @Watch('entries') changed() {
60
+ if (this.follow) {
61
+ this.$nextTick(this.scrollToLatest);
62
+ }
63
+ }
64
+ scrollToLatest() {
65
+ const feed = this.$refs.feed as HTMLElement;
66
+ if (feed) {
67
+ feed.scrollTop = feed.scrollHeight;
68
+ }
69
+ }
70
+ onScroll() {
71
+ const feed = this.$refs.feed as HTMLElement;
72
+ this.follow = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 32;
73
+ }
74
+ }
75
+ </script>
76
+ <style scoped>
77
+ .inline-game-log {
78
+ box-sizing: border-box;
79
+ width: 100%;
80
+ padding: 10px 14px;
81
+ border: 1px solid #a2aa82;
82
+ border-radius: 3px;
83
+ background: #f3f0dc;
84
+ color: #263521;
85
+ font: 14px Arial, sans-serif;
86
+ }
87
+ summary {
88
+ cursor: pointer;
89
+ font-weight: 600;
90
+ }
91
+ .journal-feed {
92
+ max-height: 220px;
93
+ overflow: auto;
94
+ overscroll-behavior: auto;
95
+ margin-top: 8px;
96
+ }
97
+ .journal-plant {
98
+ width: 76px;
99
+ height: 52px;
100
+ vertical-align: middle;
101
+ margin: 1px 4px;
102
+ overflow: visible;
103
+ }
104
+ .journal-entry {
105
+ padding: 5px 0;
106
+ border-bottom: 1px solid #75818d33;
107
+ }
108
+ </style>