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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "powergrid-viewer",
3
- "version": "1.11.22",
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": "1.15.23"
21
+ "powergrid-engine": "2.0.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/assert": "^1.4.7",
@@ -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.sendMove({ name: MoveName.Undo, data: this.preferences.undoWholeTurn });
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 (!this.paused) {
1100
- // Stamp the move so the engine can advance the per-player clocks. The
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
- const currentPlayer = this.G!.players[this.player!];
1136
- const availableMoves = currentPlayer.availableMoves!;
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
- params.emitter.on('move', (move: Move) => item.emit('move', move));
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
- item.emit('fetchState');
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', () => {
@@ -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 (move: Move) => {
45
+ emitter.on('move', async (moves: Move | Move[]) => {
46
46
  setTimeout(() => {
47
- console.log('move received', move);
48
- gameState = execMove(gameState, move, playerIndex);
49
- console.log('new game state', gameState);
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
- let newState = cloneDeep(strip ? stripSecret(gameState, playerIndex) : gameState);
61
- console.log('new game state', newState);
62
- emitter.emit('state', newState);
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
- let newState = cloneDeep(strip ? stripSecret(gameState, playerIndex) : gameState);
87
- setTimeout(() => emitter.emit('state', newState), delay);
88
- delay += delayBase;
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
+ }