powergrid-viewer 1.11.22 → 2.0.0
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.umd.min.js +1 -1
- package/package.json +2 -2
- package/src/components/Game.vue +103 -11
- package/src/launch.ts +13 -3
- package/src/self-contained.ts +31 -11
- package/src/util/turn-buffer.ts +127 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "powergrid-viewer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/boardgamers/powergrid.git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"vue": "^2.6.11",
|
|
19
19
|
"vue-class-component": "^7.2.3",
|
|
20
20
|
"vue-property-decorator": "^8.4.2",
|
|
21
|
-
"powergrid-engine": "
|
|
21
|
+
"powergrid-engine": "2.0.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/assert": "^1.4.7",
|
package/src/components/Game.vue
CHANGED
|
@@ -549,8 +549,9 @@
|
|
|
549
549
|
<script lang="ts">
|
|
550
550
|
import { Vue, Component, Prop, Watch, Provide, ProvideReactive, Ref } from 'vue-property-decorator';
|
|
551
551
|
import { MoveName, ended, playersSortedByScore, reconstructState } from 'powergrid-engine';
|
|
552
|
-
import type { GameState, Player } from 'powergrid-engine';
|
|
552
|
+
import type { GameState, LogItem, Move, Player } from 'powergrid-engine';
|
|
553
553
|
import { EventEmitter } from 'events';
|
|
554
|
+
import { matchesTurnBuffer, rebaseTurnBuffer, replayTurnBuffer as replayBuffer } from '../util/turn-buffer';
|
|
554
555
|
import { UIData, Preferences } from '../types/ui-data';
|
|
555
556
|
import { Card, House, Coal, Oil, Garbage, Uranium } from './pieces';
|
|
556
557
|
import { Button, PassButton, UndoButton, LogButton, SoundButton, HelpButton, RulesButton } from './buttons';
|
|
@@ -696,11 +697,75 @@ export default class Game extends Vue {
|
|
|
696
697
|
@Ref() map!: Map;
|
|
697
698
|
@Ref() resources!: Resources;
|
|
698
699
|
|
|
700
|
+
// Tentative-turn buffer: the moves of the current, not-yet-committed turn. The
|
|
701
|
+
// full buffer is (re)sent to the platform on every action and replayed
|
|
702
|
+
// server-side from the last committed state; undo simply shortens it.
|
|
703
|
+
turnMoves: Move[] = [];
|
|
704
|
+
|
|
705
|
+
// Last committed state received from the platform. Undo replays the shortened
|
|
706
|
+
// turn buffer from this state; when the buffer empties, the preview resets to it
|
|
707
|
+
// without any server call (the platform's saved state IS the turn start).
|
|
708
|
+
committedState: GameState | null = null;
|
|
709
|
+
|
|
699
710
|
@Watch('state', { immediate: true })
|
|
700
711
|
onStateChanged(state: GameState) {
|
|
712
|
+
if (state && state.newTurn !== false) {
|
|
713
|
+
// Committed state. Usually this clears the turn buffer (our own turn came
|
|
714
|
+
// back committed), but during the simultaneous Bureaucracy phase it can be
|
|
715
|
+
// ANOTHER player's commit landing while our turn is still tentative — then
|
|
716
|
+
// our buffer must be REBASED onto the new state, not discarded.
|
|
717
|
+
const previousLog: LogItem[] | null = this.committedState ? this.committedState.log : null;
|
|
718
|
+
this.committedState = JSON.parse(JSON.stringify(state));
|
|
719
|
+
|
|
720
|
+
if (this.turnMoves.length > 0) {
|
|
721
|
+
this.turnMoves = rebaseTurnBuffer(this.committedState!, previousLog, this.turnMoves, this.player);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (this.turnMoves.length > 0) {
|
|
725
|
+
// Preview the rebased buffer locally (dropping any move the new base
|
|
726
|
+
// no longer allows) and re-send it so the server echoes the matching
|
|
727
|
+
// tentative state.
|
|
728
|
+
const preview = this.replayTurnBuffer();
|
|
729
|
+
if (this.turnMoves.length > 0) {
|
|
730
|
+
this.emitter.emit('move', [...this.turnMoves]);
|
|
731
|
+
// Only show the preview while it is still tentative. A rebased
|
|
732
|
+
// buffer ending in a COMMITTING move (e.g. Bureaucracy
|
|
733
|
+
// [UsePowerPlant, Pass] racing another player's commit) replays
|
|
734
|
+
// hidden outcomes (deck draws, upkeep) on the STRIPPED committed
|
|
735
|
+
// state — empty deck, secret seed — so its preview would flash
|
|
736
|
+
// bogus results. Fall through to the new committed base instead;
|
|
737
|
+
// the server's echo of the real committed result of the re-sent
|
|
738
|
+
// buffer lands next and clears the buffer via the rebase above.
|
|
739
|
+
if (preview.newTurn === false) {
|
|
740
|
+
// The replay scrubber still needs the newest committed state
|
|
741
|
+
this._futureState = state;
|
|
742
|
+
this.replaceState(preview, false);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} else if (state && !matchesTurnBuffer(state, this.committedState, this.turnMoves, this.player)) {
|
|
748
|
+
// Stale tentative echo: server responses can arrive after the buffer has
|
|
749
|
+
// changed (a move was undone — possibly down to an empty buffer, which
|
|
750
|
+
// re-emits nothing — or another move was made before the echo landed).
|
|
751
|
+
// Applying it would transiently show a phantom or regressed move; the
|
|
752
|
+
// echo for the current buffer (if any) will follow, so just drop this one.
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
701
756
|
this.replaceState(state);
|
|
702
757
|
}
|
|
703
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Replays the turn buffer on the last committed state (dropping any move the
|
|
761
|
+
* engine now rejects — possible after a rebase) and returns the preview.
|
|
762
|
+
*/
|
|
763
|
+
replayTurnBuffer(): GameState {
|
|
764
|
+
const { state, applied } = replayBuffer(this.committedState!, this.turnMoves, this.player!);
|
|
765
|
+
this.turnMoves = applied;
|
|
766
|
+
return state;
|
|
767
|
+
}
|
|
768
|
+
|
|
704
769
|
replaceState(state: GameState, replaceState = true) {
|
|
705
770
|
if (replaceState) {
|
|
706
771
|
this._futureState = state;
|
|
@@ -821,7 +886,29 @@ export default class Game extends Vue {
|
|
|
821
886
|
}
|
|
822
887
|
|
|
823
888
|
undo() {
|
|
824
|
-
this.
|
|
889
|
+
if (this.paused || this.turnMoves.length === 0 || !this.committedState) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Honor the preference locally — the engine has no Undo move anymore: pop the
|
|
894
|
+
// last move from the turn buffer, or scrap the whole tentative turn.
|
|
895
|
+
if (this.preferences.undoWholeTurn) {
|
|
896
|
+
this.turnMoves = [];
|
|
897
|
+
} else {
|
|
898
|
+
this.turnMoves.pop();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (this.turnMoves.length > 0) {
|
|
902
|
+
// Preview the shortened turn locally and re-send it so the server echoes
|
|
903
|
+
// the matching tentative state.
|
|
904
|
+
const preview = this.replayTurnBuffer();
|
|
905
|
+
this.emitter.emit('move', [...this.turnMoves]);
|
|
906
|
+
this.replaceState(preview, false);
|
|
907
|
+
} else {
|
|
908
|
+
// Empty buffer: nothing to send — nothing was ever persisted for this
|
|
909
|
+
// turn, so the last committed state IS the turn start.
|
|
910
|
+
this.replaceState(this.committedState, false);
|
|
911
|
+
}
|
|
825
912
|
}
|
|
826
913
|
|
|
827
914
|
choosePowerPlant(powerPlant: PowerPlant) {
|
|
@@ -1096,12 +1183,19 @@ export default class Game extends Vue {
|
|
|
1096
1183
|
}
|
|
1097
1184
|
|
|
1098
1185
|
sendMove(move) {
|
|
1099
|
-
if (
|
|
1100
|
-
|
|
1101
|
-
// engine never reads the system clock itself — the timestamp lives in the
|
|
1102
|
-
// log so replaying a game reproduces the same times.
|
|
1103
|
-
this.emitter.emit('move', { ...move, time: Date.now() });
|
|
1186
|
+
if (this.paused) {
|
|
1187
|
+
return;
|
|
1104
1188
|
}
|
|
1189
|
+
|
|
1190
|
+
// Stamp the move ONCE, when it enters the turn buffer, so the engine can
|
|
1191
|
+
// advance the per-player clocks. The engine never reads the system clock
|
|
1192
|
+
// itself — the stamp travels with the move on every resend of the buffer, so
|
|
1193
|
+
// replays (and the eventual committed log) reproduce the same times.
|
|
1194
|
+
this.turnMoves.push({ ...move, time: Date.now() });
|
|
1195
|
+
|
|
1196
|
+
// Send the WHOLE turn so far: the platform is stateless between calls and
|
|
1197
|
+
// replays the buffer from the last committed (saved) state.
|
|
1198
|
+
this.emitter.emit('move', [...this.turnMoves]);
|
|
1105
1199
|
}
|
|
1106
1200
|
|
|
1107
1201
|
gameEnded(G: GameState) {
|
|
@@ -1132,10 +1226,8 @@ export default class Game extends Vue {
|
|
|
1132
1226
|
canUndo() {
|
|
1133
1227
|
if (!this.canMove()) return false;
|
|
1134
1228
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
return !!availableMoves[MoveName.Undo];
|
|
1229
|
+
// Undo scope = the current tentative turn: anything still in the buffer
|
|
1230
|
+
return this.turnMoves.length > 0;
|
|
1139
1231
|
}
|
|
1140
1232
|
|
|
1141
1233
|
canBid() {
|
package/src/launch.ts
CHANGED
|
@@ -31,7 +31,9 @@ function launch(selector: string) {
|
|
|
31
31
|
const item: EventEmitter = new EventEmitter();
|
|
32
32
|
let replaying = false;
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// The move payload is the whole current turn so far (an array of atomic moves),
|
|
35
|
+
// replayed by the engine wrapper from the last committed state.
|
|
36
|
+
params.emitter.on('move', (moves: Move[]) => item.emit('move', moves));
|
|
35
37
|
params.emitter.on('fetchState', () => item.emit('fetchState'));
|
|
36
38
|
params.emitter.on('addLog', (data: string[]) => item.emit('addLog', data));
|
|
37
39
|
params.emitter.on('replaceLog', (data: string[]) => item.emit('replaceLog', data));
|
|
@@ -60,12 +62,20 @@ function launch(selector: string) {
|
|
|
60
62
|
params.preferences = { ...params.preferences, ...data };
|
|
61
63
|
app.$forceUpdate();
|
|
62
64
|
});
|
|
63
|
-
item.addListener('gamelog', (
|
|
65
|
+
item.addListener('gamelog', (logData) => {
|
|
64
66
|
if (replaying) {
|
|
65
67
|
return;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
if (logData?.data?.state) {
|
|
71
|
+
// Move responses carry the (possibly tentative) resulting state. Tentative
|
|
72
|
+
// states are never persisted or broadcast by the platform — this is the
|
|
73
|
+
// only way they reach the acting player's viewer.
|
|
74
|
+
params.state = logData.data.state;
|
|
75
|
+
app.$forceUpdate();
|
|
76
|
+
} else {
|
|
77
|
+
item.emit('fetchState');
|
|
78
|
+
}
|
|
69
79
|
});
|
|
70
80
|
|
|
71
81
|
item.addListener('replay:start', () => {
|
package/src/self-contained.ts
CHANGED
|
@@ -42,12 +42,25 @@ function launchSelfContained(selector = '#app') {
|
|
|
42
42
|
if (player.id != playerIndex) player.isAI = true;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
emitter.on('move', async (
|
|
45
|
+
emitter.on('move', async (moves: Move | Move[]) => {
|
|
46
46
|
setTimeout(() => {
|
|
47
|
-
console.log('
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
console.log('moves received', moves);
|
|
48
|
+
|
|
49
|
+
// Mimic the platform: replay the whole turn buffer from the last committed
|
|
50
|
+
// state; only keep (persist) the result once the turn is committed.
|
|
51
|
+
let newState = cloneDeep(gameState);
|
|
52
|
+
for (const move of Array.isArray(moves) ? moves : [moves]) {
|
|
53
|
+
newState = execMove(newState, move, playerIndex);
|
|
54
|
+
}
|
|
55
|
+
console.log('new game state', newState);
|
|
56
|
+
|
|
57
|
+
if (newState.newTurn === false) {
|
|
58
|
+
// Tentative: just echo the state back to the acting player
|
|
59
|
+
emitter.emit('state', cloneDeep(strip ? stripSecret(newState, playerIndex) : newState));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
gameState = newState;
|
|
51
64
|
emitter.emit('state', cloneDeep(strip ? stripSecret(gameState, playerIndex) : gameState));
|
|
52
65
|
|
|
53
66
|
let delay = delayBase;
|
|
@@ -57,9 +70,13 @@ function launchSelfContained(selector = '#app') {
|
|
|
57
70
|
gameState,
|
|
58
71
|
gameState.players.findIndex((pl) => pl.isAI && pl.availableMoves)
|
|
59
72
|
);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
// Only broadcast committed states: the human viewer discards
|
|
74
|
+
// tentative states that don't match its own turn buffer.
|
|
75
|
+
if (gameState.newTurn !== false) {
|
|
76
|
+
let newAIState = cloneDeep(strip ? stripSecret(gameState, playerIndex) : gameState);
|
|
77
|
+
console.log('new game state', newAIState);
|
|
78
|
+
emitter.emit('state', newAIState);
|
|
79
|
+
}
|
|
63
80
|
setTimeout(moveAIAux, gameState.phase == Phase.Bureaucracy ? delay : 0);
|
|
64
81
|
}
|
|
65
82
|
};
|
|
@@ -83,9 +100,12 @@ function launchSelfContained(selector = '#app') {
|
|
|
83
100
|
gameState,
|
|
84
101
|
gameState.players.findIndex((pl) => pl.isAI && pl.availableMoves)
|
|
85
102
|
);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
// Only broadcast committed states (see moveAIAux above).
|
|
104
|
+
if (gameState.newTurn !== false) {
|
|
105
|
+
let newState = cloneDeep(strip ? stripSecret(gameState, playerIndex) : gameState);
|
|
106
|
+
setTimeout(() => emitter.emit('state', newState), delay);
|
|
107
|
+
delay += delayBase;
|
|
108
|
+
}
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
console.log('available moves', gameState.players[playerIndex].availableMoves);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { isEqual } from 'lodash';
|
|
2
|
+
import type { GameState, LogItem, LogMove, Move } from 'powergrid-engine';
|
|
3
|
+
import { move as engineMove } from 'powergrid-engine';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pure helpers for the viewer's tentative-turn buffer.
|
|
7
|
+
*
|
|
8
|
+
* The platform persists only COMMITTED states (engine `newTurn !== false`); a turn in
|
|
9
|
+
* progress lives solely in the acting viewer's buffer, which is (re)sent whole on every
|
|
10
|
+
* action and replayed server-side from the last committed state. These helpers decide
|
|
11
|
+
* how an incoming state relates to that buffer and how to preview the buffer locally.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compare a move echoed in a log entry with a buffered one. The engine may annotate
|
|
16
|
+
* the logged copy (`usedPlantDiscount`, `fromSupply`), so only the identity fields
|
|
17
|
+
* count: name, payload, and the stamp given when the move entered the buffer.
|
|
18
|
+
*/
|
|
19
|
+
export function moveMatches(logged: Move, buffered?: Move): boolean {
|
|
20
|
+
return (
|
|
21
|
+
!!buffered &&
|
|
22
|
+
logged.name === buffered.name &&
|
|
23
|
+
logged.time === buffered.time &&
|
|
24
|
+
isEqual(logged.data, buffered.data)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A tentative state is a valid preview only if it is the server's replay of exactly
|
|
30
|
+
* the current turn buffer: the visible log must extend the committed log by precisely
|
|
31
|
+
* the buffered moves, in order. (Tentative moves are always visible log entries —
|
|
32
|
+
* fastBid bids go to the hidden log but commit immediately.) Anything else is a stale
|
|
33
|
+
* echo — a response that raced a local undo or a newer move — and must be dropped.
|
|
34
|
+
*/
|
|
35
|
+
export function matchesTurnBuffer(
|
|
36
|
+
state: GameState,
|
|
37
|
+
committedState: GameState | null,
|
|
38
|
+
turnMoves: Move[],
|
|
39
|
+
player: number | undefined
|
|
40
|
+
): boolean {
|
|
41
|
+
if (!committedState) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const committedLength = committedState.log.length;
|
|
46
|
+
|
|
47
|
+
if (state.log.length !== committedLength + turnMoves.length) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return state.log
|
|
52
|
+
.slice(committedLength)
|
|
53
|
+
.every((item, i) => item.type === 'move' && item.player === player && moveMatches(item.move, turnMoves[i]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Adjust the turn buffer to an incoming COMMITTED state.
|
|
58
|
+
*
|
|
59
|
+
* - Our own commit echoed back: the new log contains the buffered moves — drop them
|
|
60
|
+
* (usually emptying the buffer; a racing extra move may survive if we can still act).
|
|
61
|
+
* - Someone else's commit (simultaneous Bureaucracy): the new log contains no moves of
|
|
62
|
+
* ours — keep the whole buffer, to be replayed on the new base.
|
|
63
|
+
* - Anything else (our turn superseded, e.g. auto-played after a drop, a leftover whose
|
|
64
|
+
* effect is already committed hidden — a fastBid bid — or a log that did not grow
|
|
65
|
+
* monotonically): scrap the buffer.
|
|
66
|
+
*/
|
|
67
|
+
export function rebaseTurnBuffer(
|
|
68
|
+
committed: GameState,
|
|
69
|
+
previousLog: LogItem[] | null,
|
|
70
|
+
turnMoves: Move[],
|
|
71
|
+
player: number | undefined
|
|
72
|
+
): Move[] {
|
|
73
|
+
if (!previousLog || committed.log.length < previousLog.length) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const appendedOurs = committed.log
|
|
78
|
+
.slice(previousLog.length)
|
|
79
|
+
.filter((item) => item.type === 'move' && item.player === player)
|
|
80
|
+
.map((item) => (item as LogMove).move);
|
|
81
|
+
|
|
82
|
+
if (!appendedOurs.every((move, i) => moveMatches(move, turnMoves[i]))) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const remaining = turnMoves.slice(appendedOurs.length);
|
|
87
|
+
|
|
88
|
+
// A leftover move is only replayable if we can still act on the new base. This is
|
|
89
|
+
// how buffered moves whose effect is HIDDEN get dropped: a fastBid bid goes to the
|
|
90
|
+
// engine's hidden log and commits immediately, so a chooser's [choose, bid] buffer
|
|
91
|
+
// echoes back with only the choose visible — the bid is already reflected in the
|
|
92
|
+
// committed state, and the mover has left `currentPlayers`. Scrap the rest
|
|
93
|
+
// silently instead of letting the replay reject it with an error.
|
|
94
|
+
if (remaining.length > 0 && (player === undefined || !committed.currentPlayers.includes(player))) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return remaining;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Replays the turn buffer on the last committed state, truncating it at the first
|
|
103
|
+
* move the engine rejects (possible after a rebase), and returns the preview plus the
|
|
104
|
+
* moves that survived. Tentative moves never touch the power-plant deck or the seed —
|
|
105
|
+
* any move with hidden side effects commits, ending the buffer — so replaying them on
|
|
106
|
+
* the STRIPPED committed state is exact.
|
|
107
|
+
*/
|
|
108
|
+
export function replayTurnBuffer(
|
|
109
|
+
committedState: GameState,
|
|
110
|
+
turnMoves: Move[],
|
|
111
|
+
player: number
|
|
112
|
+
): { state: GameState; applied: Move[] } {
|
|
113
|
+
let state: GameState = JSON.parse(JSON.stringify(committedState));
|
|
114
|
+
const applied: Move[] = [];
|
|
115
|
+
|
|
116
|
+
for (const move of turnMoves) {
|
|
117
|
+
try {
|
|
118
|
+
state = engineMove(state, move, player);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('dropping turn-buffer tail no longer legal on the committed state', move, err);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
applied.push(move);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { state, applied };
|
|
127
|
+
}
|