powergrid-viewer 2.1.6 → 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/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 +324 -6
- package/src/components/RoundPlanner.vue +120 -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.7",
|
|
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,161 @@ 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 !!(
|
|
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
|
+
|
|
1041
1243
|
@Watch('state', { immediate: true })
|
|
1042
1244
|
onStateChanged(state: GameState) {
|
|
1043
1245
|
if (this.tutorialMove) {
|
|
@@ -1051,6 +1253,26 @@ export default class Game extends Vue {
|
|
|
1051
1253
|
this.replaceState(state);
|
|
1052
1254
|
return;
|
|
1053
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
|
+
}
|
|
1054
1276
|
if (state && state.newTurn !== false) {
|
|
1055
1277
|
// Committed state. Usually this clears the turn buffer (our own turn came
|
|
1056
1278
|
// back committed), but during the simultaneous Bureaucracy phase it can be
|
|
@@ -1248,6 +1470,10 @@ export default class Game extends Vue {
|
|
|
1248
1470
|
}
|
|
1249
1471
|
|
|
1250
1472
|
undo() {
|
|
1473
|
+
if (this.roundPlan) {
|
|
1474
|
+
this.undoPlan();
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1251
1477
|
if (this.paused || this.turnMoves.length === 0 || !this.committedState) {
|
|
1252
1478
|
return;
|
|
1253
1479
|
}
|
|
@@ -1296,6 +1522,7 @@ export default class Game extends Vue {
|
|
|
1296
1522
|
// True when it is the auction, it's this player's turn to choose a plant, and they
|
|
1297
1523
|
// are the only remaining buyer — matching the engine's uncontested-purchase case.
|
|
1298
1524
|
isSoleBuyer(): boolean {
|
|
1525
|
+
if (this.roundPlan) return false;
|
|
1299
1526
|
if (!this.canMove() || this.G!.phase !== Phase.Auction || this.G!.chosenPowerPlant || !this.canChoose()) {
|
|
1300
1527
|
return false;
|
|
1301
1528
|
}
|
|
@@ -1344,6 +1571,19 @@ export default class Game extends Vue {
|
|
|
1344
1571
|
* ever frees money and plant capacity, so the remaining buffer always replays.
|
|
1345
1572
|
*/
|
|
1346
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
|
+
}
|
|
1347
1587
|
if (this.paused || !this.committedState) {
|
|
1348
1588
|
return;
|
|
1349
1589
|
}
|
|
@@ -1372,7 +1612,13 @@ export default class Game extends Vue {
|
|
|
1372
1612
|
* empty spaces are clickable.
|
|
1373
1613
|
*/
|
|
1374
1614
|
get bufferedBuys(): Record<string, number> {
|
|
1375
|
-
return bufferedBuyCounts(
|
|
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
|
+
);
|
|
1376
1622
|
}
|
|
1377
1623
|
|
|
1378
1624
|
bid(bid: number) {
|
|
@@ -1648,6 +1894,17 @@ export default class Game extends Vue {
|
|
|
1648
1894
|
return;
|
|
1649
1895
|
}
|
|
1650
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
|
+
}
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1651
1908
|
// What the board offered when this click was judged — the same list the
|
|
1652
1909
|
// button that produced it was enabled from.
|
|
1653
1910
|
const offeredWhenClicked =
|
|
@@ -1771,6 +2028,7 @@ export default class Game extends Vue {
|
|
|
1771
2028
|
}
|
|
1772
2029
|
|
|
1773
2030
|
canUndo() {
|
|
2031
|
+
if (this.roundPlan) return this.roundPlan.entries.length > 0;
|
|
1774
2032
|
if (!this.canMove()) return false;
|
|
1775
2033
|
|
|
1776
2034
|
// Undo scope = the current tentative turn: anything still in the buffer
|
|
@@ -2034,6 +2292,13 @@ export default class Game extends Vue {
|
|
|
2034
2292
|
}
|
|
2035
2293
|
|
|
2036
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);
|
|
2037
2302
|
// Color draft (chooseColors): prompt the current picker, even on the very
|
|
2038
2303
|
// first turn before any moves are in the log.
|
|
2039
2304
|
if (
|
|
@@ -2482,6 +2747,7 @@ export default class Game extends Vue {
|
|
|
2482
2747
|
}
|
|
2483
2748
|
|
|
2484
2749
|
beforeDestroy() {
|
|
2750
|
+
if (this.pendingPlanTimer) clearTimeout(this.pendingPlanTimer);
|
|
2485
2751
|
window.removeEventListener('resize', this.onViewportResize);
|
|
2486
2752
|
window.removeEventListener('orientationchange', this.onViewportResize);
|
|
2487
2753
|
window.removeEventListener('load', this.onViewportResize);
|
|
@@ -2542,13 +2808,41 @@ ul {
|
|
|
2542
2808
|
max-height: calc(100vh - 40px);
|
|
2543
2809
|
}
|
|
2544
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
|
+
}
|
|
2545
2834
|
.statusBar {
|
|
2546
|
-
|
|
2835
|
+
display: flex;
|
|
2836
|
+
align-items: center;
|
|
2837
|
+
gap: 12px;
|
|
2838
|
+
min-height: 40px;
|
|
2839
|
+
box-sizing: border-box;
|
|
2840
|
+
padding: 0 10px;
|
|
2547
2841
|
width: 100%;
|
|
2548
2842
|
background-color: black;
|
|
2549
2843
|
color: #fff;
|
|
2550
2844
|
text-align: center;
|
|
2551
|
-
line-height:
|
|
2845
|
+
line-height: 1.3;
|
|
2552
2846
|
font-size: 20px;
|
|
2553
2847
|
position: sticky;
|
|
2554
2848
|
top: 0;
|
|
@@ -2556,6 +2850,30 @@ ul {
|
|
|
2556
2850
|
flex-shrink: 0;
|
|
2557
2851
|
}
|
|
2558
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
|
+
|
|
2559
2877
|
#scene {
|
|
2560
2878
|
display: block;
|
|
2561
2879
|
height: auto;
|
|
@@ -0,0 +1,120 @@
|
|
|
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="queue && queue.notice" role="status" class="notice">{{ queue.notice }}</p>
|
|
32
|
+
<p v-if="pending" role="status">Saving premoves…</p>
|
|
33
|
+
<p v-if="error" role="status" class="notice">{{ error }}</p>
|
|
34
|
+
</section>
|
|
35
|
+
</template>
|
|
36
|
+
<script lang="ts">
|
|
37
|
+
import Vue from 'vue';
|
|
38
|
+
import type { RoundPlan } from 'powergrid-engine/src/planning';
|
|
39
|
+
import type { PremovePlan } from 'powergrid-engine/src/premoves';
|
|
40
|
+
export default Vue.extend({
|
|
41
|
+
props: {
|
|
42
|
+
plan: { type: Object as () => RoundPlan | null, default: null },
|
|
43
|
+
queue: { type: Object as () => PremovePlan | undefined, default: undefined },
|
|
44
|
+
liveChanged: Boolean,
|
|
45
|
+
queueable: Boolean,
|
|
46
|
+
playsNow: Boolean,
|
|
47
|
+
pending: Boolean,
|
|
48
|
+
error: String,
|
|
49
|
+
queueHint: String,
|
|
50
|
+
},
|
|
51
|
+
computed: {
|
|
52
|
+
hasQueue(): boolean {
|
|
53
|
+
return !!this.queue?.phases.length;
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
<style scoped>
|
|
59
|
+
.round-planner,
|
|
60
|
+
.planner-strip {
|
|
61
|
+
display: contents;
|
|
62
|
+
}
|
|
63
|
+
.planner-controls {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 10px;
|
|
67
|
+
flex-wrap: wrap;
|
|
68
|
+
padding: 5px 10px 5px 0;
|
|
69
|
+
font: 14px Arial, sans-serif;
|
|
70
|
+
color: #f0f2e9;
|
|
71
|
+
}
|
|
72
|
+
p {
|
|
73
|
+
margin: 6px 0;
|
|
74
|
+
}
|
|
75
|
+
button {
|
|
76
|
+
font: inherit;
|
|
77
|
+
border: 1px solid #68725f;
|
|
78
|
+
border-radius: 3px;
|
|
79
|
+
background: transparent;
|
|
80
|
+
color: inherit;
|
|
81
|
+
padding: 5px 10px;
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
}
|
|
84
|
+
button:hover {
|
|
85
|
+
background: #35412b;
|
|
86
|
+
}
|
|
87
|
+
button:disabled {
|
|
88
|
+
opacity: 0.45;
|
|
89
|
+
cursor: default;
|
|
90
|
+
}
|
|
91
|
+
.primary {
|
|
92
|
+
background: #9acd32;
|
|
93
|
+
color: #182007;
|
|
94
|
+
border-color: #9acd32;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
}
|
|
97
|
+
.primary:hover {
|
|
98
|
+
background: #b0df52;
|
|
99
|
+
}
|
|
100
|
+
.round-planner > p {
|
|
101
|
+
flex-basis: 100%;
|
|
102
|
+
padding: 0 12px;
|
|
103
|
+
margin: 0 0 8px;
|
|
104
|
+
font: 13px Arial, sans-serif;
|
|
105
|
+
}
|
|
106
|
+
.muted {
|
|
107
|
+
color: #bcc7ad;
|
|
108
|
+
font-size: 13px;
|
|
109
|
+
}
|
|
110
|
+
button:focus-visible {
|
|
111
|
+
outline: 2px solid #d7ed95;
|
|
112
|
+
outline-offset: 2px;
|
|
113
|
+
}
|
|
114
|
+
.queue-hint {
|
|
115
|
+
margin: 7px 0 0;
|
|
116
|
+
}
|
|
117
|
+
.notice {
|
|
118
|
+
color: #efcb88;
|
|
119
|
+
}
|
|
120
|
+
</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
|
+
}
|