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/dist/powergrid-viewer.css +1 -1
- package/dist/powergrid-viewer.umd.min.js +5 -5
- package/package.json +2 -2
- package/src/components/Game.vue +321 -6
- package/src/components/RoundPlanner.vue +119 -0
- package/src/launch.ts +3 -1
- package/src/self-contained.ts +8 -10
- package/src/util/round-plan.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "powergrid-viewer",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
23
|
+
"powergrid-engine": "2.0.8"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/assert": "^1.4.7",
|
package/src/components/Game.vue
CHANGED
|
@@ -1,7 +1,41 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :class="['game', { fitToScreen: preferences.fitToScreen && !stacked, stacked: stacked }]">
|
|
3
|
-
<div class="
|
|
4
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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));
|
package/src/self-contained.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { cloneDeep } from 'lodash';
|
|
2
|
-
import {
|
|
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 |
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|