powergrid-viewer 2.1.5 → 2.1.7

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.1.5",
3
+ "version": "2.1.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -11,7 +11,7 @@
11
11
  "url": "https://github.com/boardgamers/powergrid/issues"
12
12
  },
13
13
  "dependencies": {
14
- "@boardgamers/protocol": "0.2.2",
14
+ "@boardgamers/protocol": "0.3.3",
15
15
  "core-js": "^3.6.5",
16
16
  "lodash": "^4.17.19",
17
17
  "regenerator-runtime": "^0.13.7",
@@ -19,7 +19,8 @@
19
19
  "vue": "^2.6.11",
20
20
  "vue-class-component": "^7.2.3",
21
21
  "vue-property-decorator": "^9.1.2",
22
- "powergrid-engine": "2.0.6"
22
+ "zod": "4.6.4",
23
+ "powergrid-engine": "2.0.8"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@types/assert": "^1.4.7",
@@ -27,6 +28,8 @@
27
28
  "@types/events": "^3.0.0",
28
29
  "@types/lodash": "^4.14.154",
29
30
  "@types/mocha": "^5.2.4",
31
+ "@types/node": "15.3.0",
32
+ "@types/webpack-env": "1.18.8",
30
33
  "@typescript-eslint/parser": "^4.26.1",
31
34
  "@vue/cli-plugin-babel": "~4.4.0",
32
35
  "@vue/cli-plugin-typescript": "~4.4.0",
@@ -56,6 +59,10 @@
56
59
  "build": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",
57
60
  "test:unit": "vue-cli-service test:unit",
58
61
  "package": "vue-cli-service build --target lib --name powergrid-viewer src/wrapper.ts",
59
- "test:browser": "node scripts/protocol-smoke.mjs"
62
+ "test:browser": "node scripts/protocol-smoke.mjs",
63
+ "test:tutorial": "node --test tests/tutorial/lessons.cjs",
64
+ "test:tutorial:browser": "node scripts/tutorial-smoke.mjs",
65
+ "preview:tutorial": "node scripts/tutorial-preview.mjs",
66
+ "tutorial:manifest": "node scripts/tutorial-manifest.cjs"
60
67
  }
61
68
  }
@@ -1,7 +1,41 @@
1
1
  <template>
2
2
  <div :class="['game', { fitToScreen: preferences.fitToScreen && !stacked, stacked: stacked }]">
3
- <div class="statusBar">
4
- {{ getStatusMessage() }}
3
+ <div class="planner-header" :class="{ planning: hasPlanPanel }">
4
+ <div class="statusBar">
5
+ <span class="status-message">{{ getStatusMessage() }}</span>
6
+ <button
7
+ v-if="!tutorialMove && !paused && canPlanRound && !roundPlan"
8
+ class="plan-entry"
9
+ :title="
10
+ canPreparePremoves
11
+ ? 'Plan cities and powering for this round'
12
+ : 'Simulate the rest of this round with the game controls'
13
+ "
14
+ @click="myPhaseQueue && myPhaseQueue.phases.length ? viewQueuedPlan() : startPlanning()"
15
+ >
16
+ <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true">
17
+ <path d="M3 2v15h15M6 12l4-5 4 3 4-6" fill="none" stroke="currentColor" stroke-width="1.6" />
18
+ </svg>
19
+ {{ canPreparePremoves ? 'Plan' : 'Simulate'
20
+ }}<span v-if="queuedMoveCount">
21
+ · {{ queuedMoveCount }} {{ queuedMoveCount === 1 ? 'premove' : 'premoves' }}
22
+ </span>
23
+ </button>
24
+ <button v-if="roundPlan" class="plan-entry" @click="stopPlanning()">Return to live game</button>
25
+ </div>
26
+ <RoundPlanner
27
+ v-if="!tutorialMove && !paused && hasPlanPanel"
28
+ :plan="roundPlan"
29
+ :queue="myPhaseQueue"
30
+ :queueable="canQueueRound"
31
+ :plays-now="planPlaysNow"
32
+ :queue-hint="queueHint"
33
+ :pending="!!pendingPlanId"
34
+ :error="planningError"
35
+ :live-changed="planningChanged"
36
+ @queue="submitRoundPlan"
37
+ @cancel="cancelPhasePlan"
38
+ />
5
39
  </div>
6
40
  <svg
7
41
  v-if="G"
@@ -26,6 +60,8 @@
26
60
  ref="cityCount"
27
61
  :transform="`translate(${G.map.cityCountPosition[0]}, ${G.map.cityCountPosition[1]})`"
28
62
  :playerColors="playerColors"
63
+ :paymentTable="G.paymentTable"
64
+ :compact="stacked"
29
65
  :citiesToEndGame="G.citiesToEndGame"
30
66
  :citiesToStep2="G.map.name === 'Manhattan' ? undefined : G.citiesToStep2"
31
67
  />
@@ -72,7 +108,7 @@
72
108
  </template>
73
109
  </g>
74
110
 
75
- <g ref="slotPowerPlantMarket" :transform="slotT('powerPlantMarket')">
111
+ <g ref="slotPowerPlantMarket" data-tutorial="plants" :transform="slotT('powerPlantMarket')">
76
112
  <PowerPlantMarket
77
113
  ref="powerPlantMarket"
78
114
  :transform="`translate(${G.map.powerPlantMarketPosition[0]}, ${G.map.powerPlantMarketPosition[1]})`"
@@ -89,12 +125,13 @@
89
125
  />
90
126
  </g>
91
127
 
92
- <g ref="slotMap" :transform="slotT('map')">
128
+ <g ref="slotMap" data-tutorial="map" :transform="slotT('map')">
93
129
  <Map
94
130
  ref="map"
95
131
  :transform="mapTransform"
96
132
  :playerColors="playerColors"
97
133
  :cities="G.map.cities"
134
+ :tutorialCity="tutorialCity"
98
135
  :connections="G.map.connections"
99
136
  :polygons="G.map.polygons"
100
137
  :buildableCities="getBuildableCities()"
@@ -147,7 +184,7 @@
147
184
  <ResourceViewButton :showTrack="showResourceTrack" @select="setResourceView($event)" />
148
185
  </g>
149
186
 
150
- <g ref="slotResources" :transform="slotT('resources')">
187
+ <g ref="slotResources" data-tutorial="resources" :transform="slotT('resources')">
151
188
  <!-- On a phone the printed price track is a strip of unreadable
152
189
  columns, so the stacked layout defaults to one box per buyable
153
190
  source. It is still only a default: the switch above this row
@@ -356,13 +393,14 @@
356
393
 
357
394
  <g
358
395
  ref="slotButtons"
396
+ data-tutorial="turn"
359
397
  :transform="slotT('buttons') || `translate(${G.map.buttonsPosition[0]}, ${G.map.buttonsPosition[1]})`"
360
398
  >
361
399
  <PassButton
362
400
  transform="translate(15, 15)"
363
401
  :enabled="canPass()"
364
402
  :highlightButton="canPass() && !preferences.disableHelp"
365
- :text="canUndo() ? 'Done' : 'Pass'"
403
+ :text="tutorialMove || roundPlan || canUndo() ? 'Done' : 'Pass'"
366
404
  @click="checkPass()"
367
405
  />
368
406
  <UndoButton
@@ -388,7 +426,7 @@
388
426
  />
389
427
  </g>
390
428
 
391
- <g ref="slotPlayerBoards" :transform="slotT('playerBoards')">
429
+ <g ref="slotPlayerBoards" data-tutorial="players" :transform="slotT('playerBoards')">
392
430
  <template v-for="(playerIndex, i) in adjustedPlayerOrder">
393
431
  <PlayerBoard
394
432
  :key="'B' + playerIndex"
@@ -720,16 +758,21 @@
720
758
  <strong>Payment Table</strong>
721
759
  <div class="table-scroll">
722
760
  <table class="payment-table">
761
+ <!-- The whole table, so this agrees with the city track on the
762
+ board. Counting to citiesToEndGame stopped one column short
763
+ of the number that ends the game — and a player can finish
764
+ holding more cities than that, having built in the same
765
+ phase as whoever triggered it. -->
723
766
  <tr>
724
- <td><strong>Cities</strong></td>
725
- <template v-for="index in G.citiesToEndGame">
726
- <td :key="'cities' + index">{{ index - 1 }}</td>
767
+ <td><strong>Powered cities</strong></td>
768
+ <template v-for="(income, count) in G.paymentTable">
769
+ <td :key="'cities' + count">{{ count }}</td>
727
770
  </template>
728
771
  </tr>
729
772
  <tr>
730
773
  <td><strong>Payment</strong></td>
731
- <template v-for="index in G.citiesToEndGame">
732
- <td :key="'payment' + index">${{ G.paymentTable[index - 1] }}</td>
774
+ <template v-for="(income, count) in G.paymentTable">
775
+ <td :key="'payment' + count">${{ income }}</td>
733
776
  </template>
734
777
  </tr>
735
778
  </table>
@@ -741,6 +784,7 @@
741
784
  </div>
742
785
  </template>
743
786
  <script lang="ts">
787
+ import { isEqual } from 'lodash';
744
788
  import InlineLog from './InlineLog.vue';
745
789
  import { Vue, Component, Prop, Watch, Provide, ProvideReactive, Ref } from 'vue-property-decorator';
746
790
  import { MoveName, ended, playersSortedByScore, reconstructState } from 'powergrid-engine';
@@ -774,6 +818,17 @@ import PlayerOrder from './boards/PlayerOrder.vue';
774
818
  import CityCount from './boards/CityCount.vue';
775
819
  import Map from './boards/Map.vue';
776
820
  import ResourceBoxes from './boards/ResourceBoxes.vue';
821
+ import RoundPlanner from './RoundPlanner.vue';
822
+ import {
823
+ copyState,
824
+ nextPlanningPhase,
825
+ startRoundPlan,
826
+ planMove,
827
+ replayRoundPlan,
828
+ RoundPlan,
829
+ } from 'powergrid-engine/src/planning';
830
+ import { PhasePlan, PremoveCommand, canQueuePhases } from 'powergrid-engine/src/premoves';
831
+ import { completedPhases } from '../util/round-plan';
777
832
  import Resources from './boards/Resources.vue';
778
833
  import { LogMove } from 'powergrid-engine/src/log';
779
834
  import { Phase, playerTimeUsed, PowerPlant, PowerPlantType, ResourceType } from 'powergrid-engine/src/gamestate';
@@ -916,6 +971,7 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
916
971
  ResourceViewButton,
917
972
  Button,
918
973
  Calculator,
974
+ RoundPlanner,
919
975
  PowerPlantMarket,
920
976
  PlayerOrder,
921
977
  CityCount,
@@ -935,6 +991,15 @@ export default class Game extends Vue {
935
991
  @Prop()
936
992
  emitter!: EventEmitter;
937
993
 
994
+ @Prop({ default: false })
995
+ interactionDisabled!: boolean;
996
+
997
+ @Prop()
998
+ tutorialMove?: (move: Move) => void;
999
+
1000
+ @Prop()
1001
+ tutorialCity?: string;
1002
+
938
1003
  @Prop()
939
1004
  avatars!: string[];
940
1005
 
@@ -1020,8 +1085,194 @@ export default class Game extends Vue {
1020
1085
  // without any server call (the platform's saved state IS the turn start).
1021
1086
  committedState: GameState | null = null;
1022
1087
 
1088
+ roundPlan: RoundPlan | null = null;
1089
+ planningBase: GameState | null = null;
1090
+ planningError = '';
1091
+ pendingPlanId = '';
1092
+ pendingPlanIsCancel = false;
1093
+ pendingPlanTimer: ReturnType<typeof setTimeout> | undefined;
1094
+
1095
+ get canPlanRound(): boolean {
1096
+ return (
1097
+ !this.tutorialMove &&
1098
+ !this.paused &&
1099
+ !this.interactionDisabled &&
1100
+ this.player != undefined &&
1101
+ !!this.committedState &&
1102
+ !!nextPlanningPhase(this.committedState, this.player)
1103
+ );
1104
+ }
1105
+ get canPreparePremoves(): boolean {
1106
+ return !!this.committedState && this.player != null && canQueuePhases(this.committedState, this.player, []);
1107
+ }
1108
+ get hasPlanPanel(): boolean {
1109
+ return !!(
1110
+ this.roundPlan ||
1111
+ this.myPhaseQueue?.notice ||
1112
+ this.planningError ||
1113
+ this.pendingPlanId
1114
+ );
1115
+ }
1116
+ get myPhaseQueue() {
1117
+ return this.committedState?.automation?.plans[this.player!];
1118
+ }
1119
+ get queuedMoveCount(): number {
1120
+ return (this.myPhaseQueue?.phases || []).reduce(
1121
+ // An otherwise empty phase still queues the decision to finish it.
1122
+ (count, phase) => count + Math.max(1, phase.moves.filter((move) => move.name !== MoveName.Pass).length),
1123
+ 0
1124
+ );
1125
+ }
1126
+ get planningChanged(): boolean {
1127
+ if (!this.roundPlan || !this.planningBase || !this.committedState) return false;
1128
+ const base = this.planningBase,
1129
+ live = this.committedState;
1130
+ return (
1131
+ base.round !== live.round ||
1132
+ base.phase !== live.phase ||
1133
+ base.log.length !== live.log.length ||
1134
+ JSON.stringify(base.players[this.player!]) !== JSON.stringify(live.players[this.player!])
1135
+ );
1136
+ }
1137
+ get canQueueRound(): boolean {
1138
+ if (
1139
+ !this.roundPlan ||
1140
+ !this.committedState ||
1141
+ this.pendingPlanId ||
1142
+ this.turnMoves.length ||
1143
+ this.roundPlan.state.round !== this.committedState.round
1144
+ )
1145
+ return false;
1146
+ const phases = completedPhases(this.roundPlan);
1147
+ return phases.length > 0 &&
1148
+ !isEqual(phases, this.myPhaseQueue?.phases) &&
1149
+ canQueuePhases(this.committedState, this.player!, phases);
1150
+ }
1151
+ get planPlaysNow(): boolean {
1152
+ return (
1153
+ !!this.roundPlan &&
1154
+ !!this.committedState?.currentPlayers.includes(this.player!) &&
1155
+ completedPhases(this.roundPlan)[0]?.phase === this.committedState.phase
1156
+ );
1157
+ }
1158
+ get queueHint(): string {
1159
+ if (this.roundPlan && this.committedState && this.roundPlan.state.round !== this.committedState.round)
1160
+ return 'The round ended. Start a new simulation.';
1161
+ return '';
1162
+ }
1163
+ startPlanning() {
1164
+ if (!this.canPlanRound) return;
1165
+ try {
1166
+ const base = this.roundPlan ? this.committedState! : this.G!;
1167
+ this.planningBase = copyState(base);
1168
+ this.roundPlan = startRoundPlan(base, this.player!);
1169
+ this.planningError = '';
1170
+ this.showPlan();
1171
+ } catch (error) {
1172
+ this.planningError = String(error instanceof Error ? error.message : error);
1173
+ }
1174
+ }
1175
+ showPlan() {
1176
+ this.confirmVisible = this.discardVisible = this.freeJumpVisible = false;
1177
+ this.soleBuyerPlant = null;
1178
+ this.G = null;
1179
+ this.replaceState(this.roundPlan!.state, false, false);
1180
+ }
1181
+ stopPlanning() {
1182
+ this.roundPlan = null;
1183
+ this.planningBase = null;
1184
+ this.planningError = '';
1185
+ this.G = null;
1186
+ this.replaceState(this.turnMoves.length ? this.replayTurnBuffer() : this.committedState!, false, false);
1187
+ }
1188
+ undoPlan() {
1189
+ if (!this.roundPlan?.entries.length) return;
1190
+ this.roundPlan = replayRoundPlan(
1191
+ this.planningBase!,
1192
+ this.player!,
1193
+ this.roundPlan.entries.slice(0, -1),
1194
+ this.roundPlan.startPhase
1195
+ );
1196
+ this.planningError = '';
1197
+ this.showPlan();
1198
+ }
1199
+ submitRoundPlan() {
1200
+ if (!this.canQueueRound) return;
1201
+ this.sendPhasePlan(completedPhases(this.roundPlan!));
1202
+ }
1203
+ cancelPhasePlan() {
1204
+ this.sendPhasePlan([], true);
1205
+ }
1206
+ sendPhasePlan(phases: PhasePlan[], cancelling = false) {
1207
+ if (this.pendingPlanId || !this.committedState) return;
1208
+ const request: PremoveCommand = {
1209
+ type: 'premoves',
1210
+ round: this.committedState.round,
1211
+ revision: this.myPhaseQueue?.revision || 0,
1212
+ requestId: crypto.randomUUID(),
1213
+ phases,
1214
+ };
1215
+ this.pendingPlanId = request.requestId;
1216
+ this.pendingPlanIsCancel = cancelling;
1217
+ this.planningError = '';
1218
+ this.emitter.emit('premoves', request);
1219
+ this.pendingPlanTimer = setTimeout(() => {
1220
+ if (this.pendingPlanId) {
1221
+ this.pendingPlanId = '';
1222
+ this.planningError = 'The queue was not confirmed. Refresh the live game before trying again.';
1223
+ this.emitter.emit('fetchState');
1224
+ }
1225
+ }, 12000);
1226
+ }
1227
+ viewQueuedPlan() {
1228
+ if (!this.myPhaseQueue?.phases.length) return;
1229
+ this.startPlanning();
1230
+ try {
1231
+ // Viewing a powering-only queue must not add a queued pass through building.
1232
+ this.roundPlan = startRoundPlan(this.planningBase!, this.player!, this.myPhaseQueue.phases[0].phase);
1233
+ for (const phase of this.myPhaseQueue.phases) {
1234
+ for (const move of phase.moves) this.roundPlan = planMove(this.roundPlan!, move);
1235
+ }
1236
+ this.showPlan();
1237
+ } catch (error) {
1238
+ this.planningError = 'The live board changed: ' + String(error instanceof Error ? error.message : error);
1239
+ this.showPlan();
1240
+ }
1241
+ }
1242
+
1023
1243
  @Watch('state', { immediate: true })
1024
1244
  onStateChanged(state: GameState) {
1245
+ if (this.tutorialMove) {
1246
+ // Tutorial moves are individual engine checkpoints, not server turn buffers.
1247
+ // Rewinds must also clear temporary UI selections from a later step.
1248
+ this.turnMoves = [];
1249
+ this.G = null;
1250
+ this.confirmVisible = this.discardVisible = this.endScoreVisible = false;
1251
+ this.soleBuyerPlant = null;
1252
+ this.totalBid = 0;
1253
+ this.replaceState(state);
1254
+ return;
1255
+ }
1256
+ if (
1257
+ state &&
1258
+ state.newTurn !== false &&
1259
+ this.pendingPlanId &&
1260
+ state.automation?.plans[this.player!]?.requestId === this.pendingPlanId
1261
+ ) {
1262
+ this.pendingPlanId = '';
1263
+ if (this.pendingPlanTimer) clearTimeout(this.pendingPlanTimer);
1264
+ if (!this.pendingPlanIsCancel) {
1265
+ this.roundPlan = null;
1266
+ this.planningBase = null;
1267
+ }
1268
+ }
1269
+ if (this.roundPlan) {
1270
+ if (state && state.newTurn !== false) {
1271
+ this.committedState = copyState(state);
1272
+ this._futureState = state;
1273
+ }
1274
+ return;
1275
+ }
1025
1276
  if (state && state.newTurn !== false) {
1026
1277
  // Committed state. Usually this clears the turn buffer (our own turn came
1027
1278
  // back committed), but during the simultaneous Bureaucracy phase it can be
@@ -1219,6 +1470,10 @@ export default class Game extends Vue {
1219
1470
  }
1220
1471
 
1221
1472
  undo() {
1473
+ if (this.roundPlan) {
1474
+ this.undoPlan();
1475
+ return;
1476
+ }
1222
1477
  if (this.paused || this.turnMoves.length === 0 || !this.committedState) {
1223
1478
  return;
1224
1479
  }
@@ -1267,6 +1522,7 @@ export default class Game extends Vue {
1267
1522
  // True when it is the auction, it's this player's turn to choose a plant, and they
1268
1523
  // are the only remaining buyer — matching the engine's uncontested-purchase case.
1269
1524
  isSoleBuyer(): boolean {
1525
+ if (this.roundPlan) return false;
1270
1526
  if (!this.canMove() || this.G!.phase !== Phase.Auction || this.G!.chosenPowerPlant || !this.canChoose()) {
1271
1527
  return false;
1272
1528
  }
@@ -1315,6 +1571,19 @@ export default class Game extends Vue {
1315
1571
  * ever frees money and plant capacity, so the remaining buffer always replays.
1316
1572
  */
1317
1573
  unbuyResource(payload: { resource: ResourceType; side?: 'north' | 'south'; fromStorage?: boolean }) {
1574
+ if (this.roundPlan) {
1575
+ const entries = [...this.roundPlan.entries];
1576
+ const index = lastBuyIndex(
1577
+ entries.map((e) => e.move),
1578
+ payload
1579
+ );
1580
+ if (index >= 0 && this.roundPlan.state.phase === Phase.Resources) {
1581
+ entries.splice(index, 1);
1582
+ this.roundPlan = replayRoundPlan(this.planningBase!, this.player!, entries, this.roundPlan.startPhase);
1583
+ this.showPlan();
1584
+ }
1585
+ return;
1586
+ }
1318
1587
  if (this.paused || !this.committedState) {
1319
1588
  return;
1320
1589
  }
@@ -1343,7 +1612,13 @@ export default class Game extends Vue {
1343
1612
  * empty spaces are clickable.
1344
1613
  */
1345
1614
  get bufferedBuys(): Record<string, number> {
1346
- return bufferedBuyCounts(this.turnMoves);
1615
+ return bufferedBuyCounts(
1616
+ this.roundPlan
1617
+ ? this.roundPlan.state.phase === Phase.Resources
1618
+ ? this.roundPlan.entries.filter((e) => e.phase === Phase.Resources).map((e) => e.move)
1619
+ : []
1620
+ : this.turnMoves
1621
+ );
1347
1622
  }
1348
1623
 
1349
1624
  bid(bid: number) {
@@ -1610,7 +1885,23 @@ export default class Game extends Vue {
1610
1885
  }
1611
1886
 
1612
1887
  sendMove(move) {
1613
- if (this.paused) {
1888
+ if (this.paused || this.interactionDisabled) {
1889
+ return;
1890
+ }
1891
+
1892
+ if (this.tutorialMove) {
1893
+ this.tutorialMove(move);
1894
+ return;
1895
+ }
1896
+
1897
+ if (this.roundPlan) {
1898
+ try {
1899
+ this.roundPlan = planMove(this.roundPlan, move);
1900
+ this.planningError = '';
1901
+ this.showPlan();
1902
+ } catch (error) {
1903
+ this.planningError = String(error instanceof Error ? error.message : error);
1904
+ }
1614
1905
  return;
1615
1906
  }
1616
1907
 
@@ -1622,10 +1913,13 @@ export default class Game extends Vue {
1622
1913
  : undefined;
1623
1914
  const theServerOfferedThisMove = !!offeredWhenClicked && !!offeredWhenClicked[move.name];
1624
1915
 
1625
- // Stamp the move ONCE, when it enters the turn buffer, so the engine can
1626
- // advance the per-player clocks. The engine never reads the system clock
1627
- // itself — the stamp travels with the move on every resend of the buffer, so
1628
- // replays (and the eventual committed log) reproduce the same times.
1916
+ // Stamp the move ONCE, when it enters the turn buffer. This client stamp is the
1917
+ // move's identity for reconciling the buffer against the server's echo, and it
1918
+ // drives the clocks in the LOCAL preview below. The engine never reads the system
1919
+ // clock itself, so the stamp travels with the move on every resend of the buffer.
1920
+ // For the authoritative per-player clocks the server re-stamps a single server
1921
+ // clock (`serverTime`) in `wrapper.move`: browser clocks are skewed, and a stamp
1922
+ // shared across players is what keeps a fast clock from inflating its own timer.
1629
1923
  const stamped = { ...move, time: Date.now() };
1630
1924
  this.turnMoves.push(stamped);
1631
1925
 
@@ -1713,6 +2007,7 @@ export default class Game extends Vue {
1713
2007
 
1714
2008
  canMove() {
1715
2009
  return (
2010
+ !this.interactionDisabled &&
1716
2011
  this.player != undefined &&
1717
2012
  this.G &&
1718
2013
  this.G.currentPlayers.includes(this.player!) &&
@@ -1733,6 +2028,7 @@ export default class Game extends Vue {
1733
2028
  }
1734
2029
 
1735
2030
  canUndo() {
2031
+ if (this.roundPlan) return this.roundPlan.entries.length > 0;
1736
2032
  if (!this.canMove()) return false;
1737
2033
 
1738
2034
  // Undo scope = the current tentative turn: anything still in the buffer
@@ -1996,6 +2292,13 @@ export default class Game extends Vue {
1996
2292
  }
1997
2293
 
1998
2294
  getStatusMessage() {
2295
+ if (this.roundPlan)
2296
+ return this.roundPlan.finished
2297
+ ? 'Simulation complete · Round ' + this.roundPlan.state.round
2298
+ : 'Planning · ' +
2299
+ (this.roundPlan.state.phase === Phase.Bureaucracy
2300
+ ? 'Powering cities'
2301
+ : this.roundPlan.state.phase);
1999
2302
  // Color draft (chooseColors): prompt the current picker, even on the very
2000
2303
  // first turn before any moves are in the log.
2001
2304
  if (
@@ -2444,6 +2747,7 @@ export default class Game extends Vue {
2444
2747
  }
2445
2748
 
2446
2749
  beforeDestroy() {
2750
+ if (this.pendingPlanTimer) clearTimeout(this.pendingPlanTimer);
2447
2751
  window.removeEventListener('resize', this.onViewportResize);
2448
2752
  window.removeEventListener('orientationchange', this.onViewportResize);
2449
2753
  window.removeEventListener('load', this.onViewportResize);
@@ -2504,13 +2808,41 @@ ul {
2504
2808
  max-height: calc(100vh - 40px);
2505
2809
  }
2506
2810
 
2811
+ .planner-header {
2812
+ display: contents;
2813
+ }
2814
+ .planner-header.planning {
2815
+ width: 100%;
2816
+ box-sizing: border-box;
2817
+ display: flex;
2818
+ flex-wrap: wrap;
2819
+ align-items: center;
2820
+ gap: 0 12px;
2821
+ padding: 0 10px;
2822
+ color: white;
2823
+ background: black;
2824
+ position: sticky;
2825
+ top: 0;
2826
+ z-index: 10;
2827
+ }
2828
+ .planner-header.planning .statusBar {
2829
+ display: contents;
2830
+ }
2831
+ .planner-header.planning .status-message {
2832
+ min-width: 150px;
2833
+ }
2507
2834
  .statusBar {
2508
- height: 40px;
2835
+ display: flex;
2836
+ align-items: center;
2837
+ gap: 12px;
2838
+ min-height: 40px;
2839
+ box-sizing: border-box;
2840
+ padding: 0 10px;
2509
2841
  width: 100%;
2510
2842
  background-color: black;
2511
2843
  color: #fff;
2512
2844
  text-align: center;
2513
- line-height: 40px;
2845
+ line-height: 1.3;
2514
2846
  font-size: 20px;
2515
2847
  position: sticky;
2516
2848
  top: 0;
@@ -2518,6 +2850,30 @@ ul {
2518
2850
  flex-shrink: 0;
2519
2851
  }
2520
2852
 
2853
+ .status-message {
2854
+ flex: 1;
2855
+ padding: 7px 0;
2856
+ }
2857
+ .plan-entry {
2858
+ display: inline-flex;
2859
+ align-items: center;
2860
+ gap: 7px;
2861
+ border: 1px solid #69776b;
2862
+ border-radius: 4px;
2863
+ padding: 5px 9px;
2864
+ color: #e5efd9;
2865
+ background: #242e23;
2866
+ font: 500 13px system-ui, sans-serif;
2867
+ cursor: pointer;
2868
+ }
2869
+ .plan-entry:hover {
2870
+ background: #3a4c32;
2871
+ }
2872
+ .plan-entry:focus-visible {
2873
+ outline: 2px solid #e0efb6;
2874
+ outline-offset: 2px;
2875
+ }
2876
+
2521
2877
  #scene {
2522
2878
  display: block;
2523
2879
  height: auto;
@@ -2622,7 +2978,12 @@ text {
2622
2978
  .modal {
2623
2979
  display: none; /* Hidden by default */
2624
2980
  position: fixed; /* Stay in place */
2625
- z-index: 1; /* Sit on top */
2981
+ // Above the sticky status bar (z-index 10), which is painted by the page behind
2982
+ // the overlay rather than through it. The modal opens 10vh down and the bar is a
2983
+ // fixed 40px, so on a short viewport — a phone held sideways — the bar landed on
2984
+ // the dialog's own header and covered the close button, leaving no way out of the
2985
+ // dialog at all.
2986
+ z-index: 20;
2626
2987
  padding-top: 10vh; /* Location of the box */
2627
2988
  left: 0;
2628
2989
  top: 0;