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/README.md +22 -0
- package/dist/powergrid-viewer.css +1 -1
- package/dist/powergrid-viewer.umd.min.js +6 -6
- package/package.json +11 -4
- package/src/components/Game.vue +382 -21
- package/src/components/RoundPlanner.vue +120 -0
- package/src/components/boards/CityCount.vue +89 -23
- package/src/components/boards/Map.vue +19 -0
- package/src/components/pieces/House.vue +7 -1
- package/src/game-chat.ts +32 -1
- package/src/launch.ts +8 -1
- package/src/self-contained.ts +8 -10
- package/src/sounds.ts +1 -1
- package/src/tutorial/lessons.ts +646 -0
- package/src/tutorial/mount.ts +158 -0
- package/src/tutorial/tutorial.css +135 -0
- package/src/util/round-plan.ts +12 -0
- package/src/wrapper.ts +14 -3
- package/tsconfig.json +1 -1
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",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"url": "https://github.com/boardgamers/powergrid/issues"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@boardgamers/protocol": "0.
|
|
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
|
-
"
|
|
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
|
}
|
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"
|
|
@@ -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>
|
|
725
|
-
<template v-for="
|
|
726
|
-
<td :key="'cities' +
|
|
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="
|
|
732
|
-
<td :key="'payment' +
|
|
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(
|
|
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
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
|
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;
|