powergrid-viewer 2.1.6 → 2.1.8

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.6",
3
+ "version": "2.1.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -20,7 +20,7 @@
20
20
  "vue-class-component": "^7.2.3",
21
21
  "vue-property-decorator": "^9.1.2",
22
22
  "zod": "4.6.4",
23
- "powergrid-engine": "2.0.6"
23
+ "powergrid-engine": "2.0.8"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/assert": "^1.4.7",
@@ -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"
@@ -366,7 +400,7 @@
366
400
  transform="translate(15, 15)"
367
401
  :enabled="canPass()"
368
402
  :highlightButton="canPass() && !preferences.disableHelp"
369
- :text="tutorialMove || canUndo() ? 'Done' : 'Pass'"
403
+ :text="tutorialMove || roundPlan || canUndo() ? 'Done' : 'Pass'"
370
404
  @click="checkPass()"
371
405
  />
372
406
  <UndoButton
@@ -750,6 +784,7 @@
750
784
  </div>
751
785
  </template>
752
786
  <script lang="ts">
787
+ import { isEqual } from 'lodash';
753
788
  import InlineLog from './InlineLog.vue';
754
789
  import { Vue, Component, Prop, Watch, Provide, ProvideReactive, Ref } from 'vue-property-decorator';
755
790
  import { MoveName, ended, playersSortedByScore, reconstructState } from 'powergrid-engine';
@@ -783,6 +818,17 @@ import PlayerOrder from './boards/PlayerOrder.vue';
783
818
  import CityCount from './boards/CityCount.vue';
784
819
  import Map from './boards/Map.vue';
785
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';
786
832
  import Resources from './boards/Resources.vue';
787
833
  import { LogMove } from 'powergrid-engine/src/log';
788
834
  import { Phase, playerTimeUsed, PowerPlant, PowerPlantType, ResourceType } from 'powergrid-engine/src/gamestate';
@@ -925,6 +971,7 @@ const round = (n: number, digits = 2) => Number(n.toFixed(digits));
925
971
  ResourceViewButton,
926
972
  Button,
927
973
  Calculator,
974
+ RoundPlanner,
928
975
  PowerPlantMarket,
929
976
  PlayerOrder,
930
977
  CityCount,
@@ -1038,6 +1085,158 @@ export default class Game extends Vue {
1038
1085
  // without any server call (the platform's saved state IS the turn start).
1039
1086
  committedState: GameState | null = null;
1040
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 !!(this.roundPlan || this.planningError || this.pendingPlanId);
1110
+ }
1111
+ get myPhaseQueue() {
1112
+ return this.committedState?.automation?.plans[this.player!];
1113
+ }
1114
+ get queuedMoveCount(): number {
1115
+ return (this.myPhaseQueue?.phases || []).reduce(
1116
+ // An otherwise empty phase still queues the decision to finish it.
1117
+ (count, phase) => count + Math.max(1, phase.moves.filter((move) => move.name !== MoveName.Pass).length),
1118
+ 0
1119
+ );
1120
+ }
1121
+ get planningChanged(): boolean {
1122
+ if (!this.roundPlan || !this.planningBase || !this.committedState) return false;
1123
+ const base = this.planningBase,
1124
+ live = this.committedState;
1125
+ return (
1126
+ base.round !== live.round ||
1127
+ base.phase !== live.phase ||
1128
+ base.log.length !== live.log.length ||
1129
+ JSON.stringify(base.players[this.player!]) !== JSON.stringify(live.players[this.player!])
1130
+ );
1131
+ }
1132
+ get canQueueRound(): boolean {
1133
+ if (
1134
+ !this.roundPlan ||
1135
+ !this.committedState ||
1136
+ this.pendingPlanId ||
1137
+ this.turnMoves.length ||
1138
+ this.roundPlan.state.round !== this.committedState.round
1139
+ )
1140
+ return false;
1141
+ const phases = completedPhases(this.roundPlan);
1142
+ return (
1143
+ phases.length > 0 &&
1144
+ !isEqual(phases, this.myPhaseQueue?.phases) &&
1145
+ canQueuePhases(this.committedState, this.player!, phases)
1146
+ );
1147
+ }
1148
+ get planPlaysNow(): boolean {
1149
+ return (
1150
+ !!this.roundPlan &&
1151
+ !!this.committedState?.currentPlayers.includes(this.player!) &&
1152
+ completedPhases(this.roundPlan)[0]?.phase === this.committedState.phase
1153
+ );
1154
+ }
1155
+ get queueHint(): string {
1156
+ if (this.roundPlan && this.committedState && this.roundPlan.state.round !== this.committedState.round)
1157
+ return 'The round ended. Start a new simulation.';
1158
+ return '';
1159
+ }
1160
+ startPlanning() {
1161
+ if (!this.canPlanRound) return;
1162
+ try {
1163
+ const base = this.roundPlan ? this.committedState! : this.G!;
1164
+ this.planningBase = copyState(base);
1165
+ this.roundPlan = startRoundPlan(base, this.player!);
1166
+ this.planningError = '';
1167
+ this.showPlan();
1168
+ } catch (error) {
1169
+ this.planningError = String(error instanceof Error ? error.message : error);
1170
+ }
1171
+ }
1172
+ showPlan() {
1173
+ this.confirmVisible = this.discardVisible = this.freeJumpVisible = false;
1174
+ this.soleBuyerPlant = null;
1175
+ this.G = null;
1176
+ this.replaceState(this.roundPlan!.state, false, false);
1177
+ }
1178
+ stopPlanning() {
1179
+ this.roundPlan = null;
1180
+ this.planningBase = null;
1181
+ this.planningError = '';
1182
+ this.G = null;
1183
+ this.replaceState(this.turnMoves.length ? this.replayTurnBuffer() : this.committedState!, false, false);
1184
+ }
1185
+ undoPlan() {
1186
+ if (!this.roundPlan?.entries.length) return;
1187
+ this.roundPlan = replayRoundPlan(
1188
+ this.planningBase!,
1189
+ this.player!,
1190
+ this.roundPlan.entries.slice(0, -1),
1191
+ this.roundPlan.startPhase
1192
+ );
1193
+ this.planningError = '';
1194
+ this.showPlan();
1195
+ }
1196
+ submitRoundPlan() {
1197
+ if (!this.canQueueRound) return;
1198
+ this.sendPhasePlan(completedPhases(this.roundPlan!));
1199
+ }
1200
+ cancelPhasePlan() {
1201
+ this.sendPhasePlan([], true);
1202
+ }
1203
+ sendPhasePlan(phases: PhasePlan[], cancelling = false) {
1204
+ if (this.pendingPlanId || !this.committedState) return;
1205
+ const request: PremoveCommand = {
1206
+ type: 'premoves',
1207
+ round: this.committedState.round,
1208
+ revision: this.myPhaseQueue?.revision || 0,
1209
+ requestId: crypto.randomUUID(),
1210
+ phases,
1211
+ };
1212
+ this.pendingPlanId = request.requestId;
1213
+ this.pendingPlanIsCancel = cancelling;
1214
+ this.planningError = '';
1215
+ this.emitter.emit('premoves', request);
1216
+ this.pendingPlanTimer = setTimeout(() => {
1217
+ if (this.pendingPlanId) {
1218
+ this.pendingPlanId = '';
1219
+ this.planningError = 'The queue was not confirmed. Refresh the live game before trying again.';
1220
+ this.emitter.emit('fetchState');
1221
+ }
1222
+ }, 12000);
1223
+ }
1224
+ viewQueuedPlan() {
1225
+ if (!this.myPhaseQueue?.phases.length) return;
1226
+ this.startPlanning();
1227
+ try {
1228
+ // Viewing a powering-only queue must not add a queued pass through building.
1229
+ this.roundPlan = startRoundPlan(this.planningBase!, this.player!, this.myPhaseQueue.phases[0].phase);
1230
+ for (const phase of this.myPhaseQueue.phases) {
1231
+ for (const move of phase.moves) this.roundPlan = planMove(this.roundPlan!, move);
1232
+ }
1233
+ this.showPlan();
1234
+ } catch (error) {
1235
+ this.planningError = 'The live board changed: ' + String(error instanceof Error ? error.message : error);
1236
+ this.showPlan();
1237
+ }
1238
+ }
1239
+
1041
1240
  @Watch('state', { immediate: true })
1042
1241
  onStateChanged(state: GameState) {
1043
1242
  if (this.tutorialMove) {
@@ -1051,6 +1250,26 @@ export default class Game extends Vue {
1051
1250
  this.replaceState(state);
1052
1251
  return;
1053
1252
  }
1253
+ if (
1254
+ state &&
1255
+ state.newTurn !== false &&
1256
+ this.pendingPlanId &&
1257
+ state.automation?.plans[this.player!]?.requestId === this.pendingPlanId
1258
+ ) {
1259
+ this.pendingPlanId = '';
1260
+ if (this.pendingPlanTimer) clearTimeout(this.pendingPlanTimer);
1261
+ if (!this.pendingPlanIsCancel) {
1262
+ this.roundPlan = null;
1263
+ this.planningBase = null;
1264
+ }
1265
+ }
1266
+ if (this.roundPlan) {
1267
+ if (state && state.newTurn !== false) {
1268
+ this.committedState = copyState(state);
1269
+ this._futureState = state;
1270
+ }
1271
+ return;
1272
+ }
1054
1273
  if (state && state.newTurn !== false) {
1055
1274
  // Committed state. Usually this clears the turn buffer (our own turn came
1056
1275
  // back committed), but during the simultaneous Bureaucracy phase it can be
@@ -1248,6 +1467,10 @@ export default class Game extends Vue {
1248
1467
  }
1249
1468
 
1250
1469
  undo() {
1470
+ if (this.roundPlan) {
1471
+ this.undoPlan();
1472
+ return;
1473
+ }
1251
1474
  if (this.paused || this.turnMoves.length === 0 || !this.committedState) {
1252
1475
  return;
1253
1476
  }
@@ -1296,6 +1519,7 @@ export default class Game extends Vue {
1296
1519
  // True when it is the auction, it's this player's turn to choose a plant, and they
1297
1520
  // are the only remaining buyer — matching the engine's uncontested-purchase case.
1298
1521
  isSoleBuyer(): boolean {
1522
+ if (this.roundPlan) return false;
1299
1523
  if (!this.canMove() || this.G!.phase !== Phase.Auction || this.G!.chosenPowerPlant || !this.canChoose()) {
1300
1524
  return false;
1301
1525
  }
@@ -1344,6 +1568,19 @@ export default class Game extends Vue {
1344
1568
  * ever frees money and plant capacity, so the remaining buffer always replays.
1345
1569
  */
1346
1570
  unbuyResource(payload: { resource: ResourceType; side?: 'north' | 'south'; fromStorage?: boolean }) {
1571
+ if (this.roundPlan) {
1572
+ const entries = [...this.roundPlan.entries];
1573
+ const index = lastBuyIndex(
1574
+ entries.map((e) => e.move),
1575
+ payload
1576
+ );
1577
+ if (index >= 0 && this.roundPlan.state.phase === Phase.Resources) {
1578
+ entries.splice(index, 1);
1579
+ this.roundPlan = replayRoundPlan(this.planningBase!, this.player!, entries, this.roundPlan.startPhase);
1580
+ this.showPlan();
1581
+ }
1582
+ return;
1583
+ }
1347
1584
  if (this.paused || !this.committedState) {
1348
1585
  return;
1349
1586
  }
@@ -1372,7 +1609,13 @@ export default class Game extends Vue {
1372
1609
  * empty spaces are clickable.
1373
1610
  */
1374
1611
  get bufferedBuys(): Record<string, number> {
1375
- return bufferedBuyCounts(this.turnMoves);
1612
+ return bufferedBuyCounts(
1613
+ this.roundPlan
1614
+ ? this.roundPlan.state.phase === Phase.Resources
1615
+ ? this.roundPlan.entries.filter((e) => e.phase === Phase.Resources).map((e) => e.move)
1616
+ : []
1617
+ : this.turnMoves
1618
+ );
1376
1619
  }
1377
1620
 
1378
1621
  bid(bid: number) {
@@ -1648,6 +1891,17 @@ export default class Game extends Vue {
1648
1891
  return;
1649
1892
  }
1650
1893
 
1894
+ if (this.roundPlan) {
1895
+ try {
1896
+ this.roundPlan = planMove(this.roundPlan, move);
1897
+ this.planningError = '';
1898
+ this.showPlan();
1899
+ } catch (error) {
1900
+ this.planningError = String(error instanceof Error ? error.message : error);
1901
+ }
1902
+ return;
1903
+ }
1904
+
1651
1905
  // What the board offered when this click was judged — the same list the
1652
1906
  // button that produced it was enabled from.
1653
1907
  const offeredWhenClicked =
@@ -1771,6 +2025,7 @@ export default class Game extends Vue {
1771
2025
  }
1772
2026
 
1773
2027
  canUndo() {
2028
+ if (this.roundPlan) return this.roundPlan.entries.length > 0;
1774
2029
  if (!this.canMove()) return false;
1775
2030
 
1776
2031
  // Undo scope = the current tentative turn: anything still in the buffer
@@ -2034,6 +2289,13 @@ export default class Game extends Vue {
2034
2289
  }
2035
2290
 
2036
2291
  getStatusMessage() {
2292
+ if (this.roundPlan)
2293
+ return this.roundPlan.finished
2294
+ ? 'Simulation complete · Round ' + this.roundPlan.state.round
2295
+ : 'Planning · ' +
2296
+ (this.roundPlan.state.phase === Phase.Bureaucracy
2297
+ ? 'Powering cities'
2298
+ : this.roundPlan.state.phase);
2037
2299
  // Color draft (chooseColors): prompt the current picker, even on the very
2038
2300
  // first turn before any moves are in the log.
2039
2301
  if (
@@ -2482,6 +2744,7 @@ export default class Game extends Vue {
2482
2744
  }
2483
2745
 
2484
2746
  beforeDestroy() {
2747
+ if (this.pendingPlanTimer) clearTimeout(this.pendingPlanTimer);
2485
2748
  window.removeEventListener('resize', this.onViewportResize);
2486
2749
  window.removeEventListener('orientationchange', this.onViewportResize);
2487
2750
  window.removeEventListener('load', this.onViewportResize);
@@ -2542,13 +2805,41 @@ ul {
2542
2805
  max-height: calc(100vh - 40px);
2543
2806
  }
2544
2807
 
2808
+ .planner-header {
2809
+ display: contents;
2810
+ }
2811
+ .planner-header.planning {
2812
+ width: 100%;
2813
+ box-sizing: border-box;
2814
+ display: flex;
2815
+ flex-wrap: wrap;
2816
+ align-items: center;
2817
+ gap: 0 12px;
2818
+ padding: 0 10px;
2819
+ color: white;
2820
+ background: black;
2821
+ position: sticky;
2822
+ top: 0;
2823
+ z-index: 10;
2824
+ }
2825
+ .planner-header.planning .statusBar {
2826
+ display: contents;
2827
+ }
2828
+ .planner-header.planning .status-message {
2829
+ min-width: 150px;
2830
+ }
2545
2831
  .statusBar {
2546
- height: 40px;
2832
+ display: flex;
2833
+ align-items: center;
2834
+ gap: 12px;
2835
+ min-height: 40px;
2836
+ box-sizing: border-box;
2837
+ padding: 0 10px;
2547
2838
  width: 100%;
2548
2839
  background-color: black;
2549
2840
  color: #fff;
2550
2841
  text-align: center;
2551
- line-height: 40px;
2842
+ line-height: 1.3;
2552
2843
  font-size: 20px;
2553
2844
  position: sticky;
2554
2845
  top: 0;
@@ -2556,6 +2847,30 @@ ul {
2556
2847
  flex-shrink: 0;
2557
2848
  }
2558
2849
 
2850
+ .status-message {
2851
+ flex: 1;
2852
+ padding: 7px 0;
2853
+ }
2854
+ .plan-entry {
2855
+ display: inline-flex;
2856
+ align-items: center;
2857
+ gap: 7px;
2858
+ border: 1px solid #69776b;
2859
+ border-radius: 4px;
2860
+ padding: 5px 9px;
2861
+ color: #e5efd9;
2862
+ background: #242e23;
2863
+ font: 500 13px system-ui, sans-serif;
2864
+ cursor: pointer;
2865
+ }
2866
+ .plan-entry:hover {
2867
+ background: #3a4c32;
2868
+ }
2869
+ .plan-entry:focus-visible {
2870
+ outline: 2px solid #e0efb6;
2871
+ outline-offset: 2px;
2872
+ }
2873
+
2559
2874
  #scene {
2560
2875
  display: block;
2561
2876
  height: auto;
@@ -0,0 +1,119 @@
1
+ <template>
2
+ <section class="round-planner" aria-label="Round planning">
3
+ <template v-if="plan">
4
+ <div class="planner-strip">
5
+ <div v-if="queueable || hasQueue" class="planner-controls">
6
+ <button
7
+ v-if="queueable"
8
+ class="primary"
9
+ :disabled="pending"
10
+ :title="
11
+ playsNow
12
+ ? 'Play this phase and queue the remaining moves'
13
+ : 'Queue these moves for your turn'
14
+ "
15
+ @click="$emit('queue')"
16
+ >
17
+ Validate
18
+ </button>
19
+ <button v-if="hasQueue" :disabled="pending" @click="$emit('cancel')">Cancel premoves</button>
20
+ </div>
21
+ </div>
22
+ <p v-if="!queueable && queueHint" class="muted queue-hint">{{ queueHint }}</p>
23
+ <p v-if="liveChanged" class="notice">
24
+ The live game changed. Costs and fuel will be checked again before queueing.
25
+ </p>
26
+ <p v-if="plan.finalScoring" class="muted">
27
+ The end-game threshold is reached. Final scoring uses the most cities you can power with your fuel;
28
+ there is no normal income payment.
29
+ </p>
30
+ </template>
31
+ <p v-if="pending" role="status">Saving premoves…</p>
32
+ <p v-if="error" role="status" class="notice">{{ error }}</p>
33
+ </section>
34
+ </template>
35
+ <script lang="ts">
36
+ import Vue from 'vue';
37
+ import type { RoundPlan } from 'powergrid-engine/src/planning';
38
+ import type { PremovePlan } from 'powergrid-engine/src/premoves';
39
+ export default Vue.extend({
40
+ props: {
41
+ plan: { type: Object as () => RoundPlan | null, default: null },
42
+ queue: { type: Object as () => PremovePlan | undefined, default: undefined },
43
+ liveChanged: Boolean,
44
+ queueable: Boolean,
45
+ playsNow: Boolean,
46
+ pending: Boolean,
47
+ error: String,
48
+ queueHint: String,
49
+ },
50
+ computed: {
51
+ hasQueue(): boolean {
52
+ return !!this.queue?.phases.length;
53
+ },
54
+ },
55
+ });
56
+ </script>
57
+ <style scoped>
58
+ .round-planner,
59
+ .planner-strip {
60
+ display: contents;
61
+ }
62
+ .planner-controls {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 10px;
66
+ flex-wrap: wrap;
67
+ padding: 5px 10px 5px 0;
68
+ font: 14px Arial, sans-serif;
69
+ color: #f0f2e9;
70
+ }
71
+ p {
72
+ margin: 6px 0;
73
+ }
74
+ button {
75
+ font: inherit;
76
+ border: 1px solid #68725f;
77
+ border-radius: 3px;
78
+ background: transparent;
79
+ color: inherit;
80
+ padding: 5px 10px;
81
+ cursor: pointer;
82
+ }
83
+ button:hover {
84
+ background: #35412b;
85
+ }
86
+ button:disabled {
87
+ opacity: 0.45;
88
+ cursor: default;
89
+ }
90
+ .primary {
91
+ background: #9acd32;
92
+ color: #182007;
93
+ border-color: #9acd32;
94
+ font-weight: 600;
95
+ }
96
+ .primary:hover {
97
+ background: #b0df52;
98
+ }
99
+ .round-planner > p {
100
+ flex-basis: 100%;
101
+ padding: 0 12px;
102
+ margin: 0 0 8px;
103
+ font: 13px Arial, sans-serif;
104
+ }
105
+ .muted {
106
+ color: #bcc7ad;
107
+ font-size: 13px;
108
+ }
109
+ button:focus-visible {
110
+ outline: 2px solid #d7ed95;
111
+ outline-offset: 2px;
112
+ }
113
+ .queue-hint {
114
+ margin: 7px 0 0;
115
+ }
116
+ .notice {
117
+ color: #efcb88;
118
+ }
119
+ </style>
package/src/launch.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createViewer } from '@boardgamers/protocol/viewer';
2
2
  import { EventEmitter } from 'events';
3
3
  import type { GameState, Move } from 'powergrid-engine';
4
+ import type { PremoveCommand } from 'powergrid-engine/src/premoves';
4
5
  import Vue from 'vue';
5
6
  import Game from './components/Game.vue';
6
7
  import { mountGameChat } from './game-chat';
@@ -51,7 +52,7 @@ function launch(selector: string) {
51
52
  }).$mount(mountPoint);
52
53
 
53
54
  let replaying = false;
54
- const viewer = createViewer<GameState, Move[]>({
55
+ const viewer = createViewer<GameState, Move[] | PremoveCommand>({
55
56
  async onState(data) {
56
57
  params.state = data;
57
58
  app.$forceUpdate();
@@ -97,6 +98,7 @@ function launch(selector: string) {
97
98
  });
98
99
  const item = viewer.emitter;
99
100
  params.emitter.on('move', (moves: Move[]) => viewer.move(moves));
101
+ params.emitter.on('premoves', (command: PremoveCommand) => viewer.move(command));
100
102
  params.emitter.on('fetchState', () => viewer.fetchState());
101
103
  params.emitter.on('addLog', (data: string[]) => viewer.addLog(data));
102
104
  params.emitter.on('replaceLog', (data: string[]) => viewer.replaceLog(data));
@@ -1,7 +1,8 @@
1
1
  import { cloneDeep } from 'lodash';
2
- import { move as execMove, Move, Phase, setup, stripSecret } from 'powergrid-engine';
3
- import { moveAI } from 'powergrid-engine/src/engine';
2
+ import { Move, Phase, setup, stripSecret } from 'powergrid-engine';
4
3
  import type { MapName, Variant } from 'powergrid-engine/src/gamestate';
4
+ import type { PremoveCommand } from 'powergrid-engine/src/premoves';
5
+ import { move as wrapperMove, moveAI as wrapperAI } from 'powergrid-engine/wrapper';
5
6
  import { installLocalChat } from './game-chat';
6
7
  import launch from './launch';
7
8
  import { mountSoundTests } from './sounds';
@@ -60,16 +61,13 @@ function launchSelfContained(selector = '#app') {
60
61
  if (player.id != playerIndex) player.isAI = true;
61
62
  }
62
63
 
63
- emitter.on('move', async (moves: Move | Move[]) => {
64
- setTimeout(() => {
64
+ emitter.on('move', async (moves: Move[] | PremoveCommand) => {
65
+ setTimeout(async () => {
65
66
  console.log('moves received', moves);
66
67
 
67
68
  // Mimic the platform: replay the whole turn buffer from the last committed
68
69
  // state; only keep (persist) the result once the turn is committed.
69
- let newState = cloneDeep(gameState);
70
- for (const move of Array.isArray(moves) ? moves : [moves]) {
71
- newState = execMove(newState, move, playerIndex);
72
- }
70
+ const newState = await wrapperMove(cloneDeep(gameState), moves, playerIndex);
73
71
  console.log('new game state', newState);
74
72
 
75
73
  if (newState.newTurn === false) {
@@ -84,7 +82,7 @@ function launchSelfContained(selector = '#app') {
84
82
  let delay = delayBase;
85
83
  const moveAIAux = () => {
86
84
  if (gameState.players.some((pl) => pl.isAI && pl.availableMoves)) {
87
- gameState = moveAI(
85
+ gameState = wrapperAI(
88
86
  gameState,
89
87
  gameState.players.findIndex((pl) => pl.isAI && pl.availableMoves)
90
88
  );
@@ -114,7 +112,7 @@ function launchSelfContained(selector = '#app') {
114
112
 
115
113
  let delay = delayBase;
116
114
  while (gameState.players.some((pl) => pl.isAI && pl.availableMoves)) {
117
- gameState = moveAI(
115
+ gameState = wrapperAI(
118
116
  gameState,
119
117
  gameState.players.findIndex((pl) => pl.isAI && pl.availableMoves)
120
118
  );
@@ -0,0 +1,12 @@
1
+ import { MoveName, Phase } from 'powergrid-engine';
2
+ import type { RoundPlan } from 'powergrid-engine/src/planning';
3
+ import type { PhasePlan } from 'powergrid-engine/src/premoves';
4
+
5
+ export function completedPhases(plan: RoundPlan): PhasePlan[] {
6
+ return [Phase.Building, Phase.Bureaucracy].flatMap((phase) => {
7
+ const moves = plan.entries.filter((e) => e.phase === phase).map((e) => e.move);
8
+ return moves.length && moves[moves.length - 1].name === MoveName.Pass
9
+ ? [{ phase: phase as Phase.Building | Phase.Bureaucracy, moves }]
10
+ : [];
11
+ });
12
+ }