powergrid-viewer 2.1.4 → 2.1.6

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": "2.1.4",
3
+ "version": "2.1.6",
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.2.2",
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,6 +19,7 @@
19
19
  "vue": "^2.6.11",
20
20
  "vue-class-component": "^7.2.3",
21
21
  "vue-property-decorator": "^9.1.2",
22
+ "zod": "4.6.4",
22
23
  "powergrid-engine": "2.0.6"
23
24
  },
24
25
  "devDependencies": {
@@ -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
  }
@@ -26,6 +26,8 @@
26
26
  ref="cityCount"
27
27
  :transform="`translate(${G.map.cityCountPosition[0]}, ${G.map.cityCountPosition[1]})`"
28
28
  :playerColors="playerColors"
29
+ :paymentTable="G.paymentTable"
30
+ :compact="stacked"
29
31
  :citiesToEndGame="G.citiesToEndGame"
30
32
  :citiesToStep2="G.map.name === 'Manhattan' ? undefined : G.citiesToStep2"
31
33
  />
@@ -72,7 +74,7 @@
72
74
  </template>
73
75
  </g>
74
76
 
75
- <g ref="slotPowerPlantMarket" :transform="slotT('powerPlantMarket')">
77
+ <g ref="slotPowerPlantMarket" data-tutorial="plants" :transform="slotT('powerPlantMarket')">
76
78
  <PowerPlantMarket
77
79
  ref="powerPlantMarket"
78
80
  :transform="`translate(${G.map.powerPlantMarketPosition[0]}, ${G.map.powerPlantMarketPosition[1]})`"
@@ -89,12 +91,13 @@
89
91
  />
90
92
  </g>
91
93
 
92
- <g ref="slotMap" :transform="slotT('map')">
94
+ <g ref="slotMap" data-tutorial="map" :transform="slotT('map')">
93
95
  <Map
94
96
  ref="map"
95
97
  :transform="mapTransform"
96
98
  :playerColors="playerColors"
97
99
  :cities="G.map.cities"
100
+ :tutorialCity="tutorialCity"
98
101
  :connections="G.map.connections"
99
102
  :polygons="G.map.polygons"
100
103
  :buildableCities="getBuildableCities()"
@@ -147,7 +150,7 @@
147
150
  <ResourceViewButton :showTrack="showResourceTrack" @select="setResourceView($event)" />
148
151
  </g>
149
152
 
150
- <g ref="slotResources" :transform="slotT('resources')">
153
+ <g ref="slotResources" data-tutorial="resources" :transform="slotT('resources')">
151
154
  <!-- On a phone the printed price track is a strip of unreadable
152
155
  columns, so the stacked layout defaults to one box per buyable
153
156
  source. It is still only a default: the switch above this row
@@ -356,13 +359,14 @@
356
359
 
357
360
  <g
358
361
  ref="slotButtons"
362
+ data-tutorial="turn"
359
363
  :transform="slotT('buttons') || `translate(${G.map.buttonsPosition[0]}, ${G.map.buttonsPosition[1]})`"
360
364
  >
361
365
  <PassButton
362
366
  transform="translate(15, 15)"
363
367
  :enabled="canPass()"
364
368
  :highlightButton="canPass() && !preferences.disableHelp"
365
- :text="canUndo() ? 'Done' : 'Pass'"
369
+ :text="tutorialMove || canUndo() ? 'Done' : 'Pass'"
366
370
  @click="checkPass()"
367
371
  />
368
372
  <UndoButton
@@ -388,7 +392,7 @@
388
392
  />
389
393
  </g>
390
394
 
391
- <g ref="slotPlayerBoards" :transform="slotT('playerBoards')">
395
+ <g ref="slotPlayerBoards" data-tutorial="players" :transform="slotT('playerBoards')">
392
396
  <template v-for="(playerIndex, i) in adjustedPlayerOrder">
393
397
  <PlayerBoard
394
398
  :key="'B' + playerIndex"
@@ -720,16 +724,21 @@
720
724
  <strong>Payment Table</strong>
721
725
  <div class="table-scroll">
722
726
  <table class="payment-table">
727
+ <!-- The whole table, so this agrees with the city track on the
728
+ board. Counting to citiesToEndGame stopped one column short
729
+ of the number that ends the game — and a player can finish
730
+ holding more cities than that, having built in the same
731
+ phase as whoever triggered it. -->
723
732
  <tr>
724
- <td><strong>Cities</strong></td>
725
- <template v-for="index in G.citiesToEndGame">
726
- <td :key="'cities' + index">{{ index - 1 }}</td>
733
+ <td><strong>Powered cities</strong></td>
734
+ <template v-for="(income, count) in G.paymentTable">
735
+ <td :key="'cities' + count">{{ count }}</td>
727
736
  </template>
728
737
  </tr>
729
738
  <tr>
730
739
  <td><strong>Payment</strong></td>
731
- <template v-for="index in G.citiesToEndGame">
732
- <td :key="'payment' + index">${{ G.paymentTable[index - 1] }}</td>
740
+ <template v-for="(income, count) in G.paymentTable">
741
+ <td :key="'payment' + count">${{ income }}</td>
733
742
  </template>
734
743
  </tr>
735
744
  </table>
@@ -935,6 +944,15 @@ export default class Game extends Vue {
935
944
  @Prop()
936
945
  emitter!: EventEmitter;
937
946
 
947
+ @Prop({ default: false })
948
+ interactionDisabled!: boolean;
949
+
950
+ @Prop()
951
+ tutorialMove?: (move: Move) => void;
952
+
953
+ @Prop()
954
+ tutorialCity?: string;
955
+
938
956
  @Prop()
939
957
  avatars!: string[];
940
958
 
@@ -1022,6 +1040,17 @@ export default class Game extends Vue {
1022
1040
 
1023
1041
  @Watch('state', { immediate: true })
1024
1042
  onStateChanged(state: GameState) {
1043
+ if (this.tutorialMove) {
1044
+ // Tutorial moves are individual engine checkpoints, not server turn buffers.
1045
+ // Rewinds must also clear temporary UI selections from a later step.
1046
+ this.turnMoves = [];
1047
+ this.G = null;
1048
+ this.confirmVisible = this.discardVisible = this.endScoreVisible = false;
1049
+ this.soleBuyerPlant = null;
1050
+ this.totalBid = 0;
1051
+ this.replaceState(state);
1052
+ return;
1053
+ }
1025
1054
  if (state && state.newTurn !== false) {
1026
1055
  // Committed state. Usually this clears the turn buffer (our own turn came
1027
1056
  // back committed), but during the simultaneous Bureaucracy phase it can be
@@ -1610,7 +1639,12 @@ export default class Game extends Vue {
1610
1639
  }
1611
1640
 
1612
1641
  sendMove(move) {
1613
- if (this.paused) {
1642
+ if (this.paused || this.interactionDisabled) {
1643
+ return;
1644
+ }
1645
+
1646
+ if (this.tutorialMove) {
1647
+ this.tutorialMove(move);
1614
1648
  return;
1615
1649
  }
1616
1650
 
@@ -1622,10 +1656,13 @@ export default class Game extends Vue {
1622
1656
  : undefined;
1623
1657
  const theServerOfferedThisMove = !!offeredWhenClicked && !!offeredWhenClicked[move.name];
1624
1658
 
1625
- // Stamp the move ONCE, when it enters the turn buffer, so the engine can
1626
- // advance the per-player clocks. The engine never reads the system clock
1627
- // itself — the stamp travels with the move on every resend of the buffer, so
1628
- // replays (and the eventual committed log) reproduce the same times.
1659
+ // Stamp the move ONCE, when it enters the turn buffer. This client stamp is the
1660
+ // move's identity for reconciling the buffer against the server's echo, and it
1661
+ // drives the clocks in the LOCAL preview below. The engine never reads the system
1662
+ // clock itself, so the stamp travels with the move on every resend of the buffer.
1663
+ // For the authoritative per-player clocks the server re-stamps a single server
1664
+ // clock (`serverTime`) in `wrapper.move`: browser clocks are skewed, and a stamp
1665
+ // shared across players is what keeps a fast clock from inflating its own timer.
1629
1666
  const stamped = { ...move, time: Date.now() };
1630
1667
  this.turnMoves.push(stamped);
1631
1668
 
@@ -1713,6 +1750,7 @@ export default class Game extends Vue {
1713
1750
 
1714
1751
  canMove() {
1715
1752
  return (
1753
+ !this.interactionDisabled &&
1716
1754
  this.player != undefined &&
1717
1755
  this.G &&
1718
1756
  this.G.currentPlayers.includes(this.player!) &&
@@ -2622,7 +2660,12 @@ text {
2622
2660
  .modal {
2623
2661
  display: none; /* Hidden by default */
2624
2662
  position: fixed; /* Stay in place */
2625
- z-index: 1; /* Sit on top */
2663
+ // Above the sticky status bar (z-index 10), which is painted by the page behind
2664
+ // the overlay rather than through it. The modal opens 10vh down and the bar is a
2665
+ // fixed 40px, so on a short viewport — a phone held sideways — the bar landed on
2666
+ // the dialog's own header and covered the close button, leaving no way out of the
2667
+ // dialog at all.
2668
+ z-index: 20;
2626
2669
  padding-top: 10vh; /* Location of the box */
2627
2670
  left: 0;
2628
2671
  top: 0;
@@ -1,40 +1,72 @@
1
1
  <template>
2
- <g>
3
- <rect width="730" height="50" x="10" y="10" rx="3" fill="goldenrod" />
4
- <template v-for="index in 21">
2
+ <g class="city-count-track">
3
+ <g v-for="row in compact ? 2 : 1" :key="'row' + row" :transform="`translate(0, ${(row - 1) * 70})`">
4
+ <rect :width="compact ? 367 : 730" height="68" x="10" y="10" rx="3" fill="goldenrod" />
5
+ <rect :width="compact ? 359 : 722" height="20" x="14" y="56" rx="2" fill="#365343" />
6
+ </g>
7
+ <g
8
+ v-for="(income, count) in paymentTable"
9
+ :key="count"
10
+ :transform="`translate(${cellX(count)}, ${cellY(count)})`"
11
+ >
12
+ <title>
13
+ {{ count }} powered {{ count === 1 ? 'city' : 'cities' }}: ${{ income }} total income, before any map
14
+ penalties. Houses mark cities built.
15
+ </title>
5
16
  <rect
6
- :key="'playerCityCount' + index"
7
17
  width="28"
8
18
  height="38"
9
- :x="46 + 33 * (index - 1)"
10
- y="16"
19
+ x="13"
20
+ y="14"
11
21
  rx="2"
12
22
  fill="darkgoldenrod"
13
- :stroke="index == citiesToStep2 || index == citiesToEndGame ? '#916a08' : 'darkgoldenrod'"
23
+ :stroke="count == citiesToStep2 || count == citiesToEndGame ? '#916a08' : 'darkgoldenrod'"
14
24
  stroke-width="2px"
15
25
  />
16
26
  <text
17
- :key="'playerCityCountText' + index"
18
27
  text-anchor="middle"
19
28
  style="font-size: 24px; font-family: monospace"
20
29
  letter-spacing="-2"
21
- :x="59 + 33 * (index - 1)"
22
- y="35"
30
+ x="26"
31
+ y="27"
23
32
  fill="gold"
24
33
  >
25
- {{ index }}
34
+ {{ count }}
26
35
  </text>
27
- </template>
28
- <template v-for="house in houses">
36
+ <line v-if="count % (compact ? 11 : 22) > 0" x1="10.5" x2="10.5" y1="61" y2="71" stroke="#607765" />
37
+ <text
38
+ class="city-income"
39
+ x="27"
40
+ y="66"
41
+ text-anchor="middle"
42
+ style="font-size: 15px; dominant-baseline: central"
43
+ fill="#fff3cf"
44
+ >
45
+ {{ income }}
46
+ </text>
47
+ </g>
48
+ <!-- Keep row placement synchronous so the portrait layout measures the new track bounds. -->
49
+ <g
50
+ v-for="house in houses"
51
+ :key="house.id"
52
+ :transform="`translate(${cellX(house.cityCount)}, ${cellY(house.cityCount)})`"
53
+ >
29
54
  <House
30
- :key="house.id"
31
55
  :pieceId="house.id"
32
56
  :targetState="{ x: house.x, y: house.y }"
33
57
  :owner="house.owner"
34
58
  :ownerName="house.ownerName"
35
59
  :color="house.color"
60
+ :scale="0.03"
36
61
  />
37
- </template>
62
+ </g>
63
+ <g class="city-income-legend" :transform="`translate(0, ${compact ? 157 : 87})`">
64
+ <circle cx="21" cy="0" r="7" fill="#365343" />
65
+ <text x="21" text-anchor="middle" font-size="11" fill="#fff3cf">$</text>
66
+ <text x="33" y="0" style="font-size: 13px; dominant-baseline: central" fill="#29432e">
67
+ Total income for powered cities
68
+ </text>
69
+ </g>
38
70
  </g>
39
71
  </template>
40
72
 
@@ -44,6 +76,15 @@ import { Vue, Component, Prop } from 'vue-property-decorator';
44
76
  import { House } from '../pieces';
45
77
  import { Piece } from '../../types/ui-data';
46
78
 
79
+ // The House piece at the 0.03 scale this track draws it, rounded to whole units: the
80
+ // path spans 3-396 x 4-456, so 12 x 14 on the board. Used as the grid pitch below.
81
+ const HOUSE_WIDTH = 12;
82
+ const HOUSE_HEIGHT = 14;
83
+ // The cell rect the template draws at x 13 y 14, 28 x 38 — houses are placed against
84
+ // its bottom edge, clear of the income strip that starts 4 units below it.
85
+ const CELL_CENTRE_X = 27;
86
+ const CELL_BOTTOM = 52;
87
+
47
88
  @Component({
48
89
  components: {
49
90
  House
@@ -53,8 +94,18 @@ export default class CityCount extends Vue {
53
94
  @Prop() citiesToStep2?: number;
54
95
  @Prop() citiesToEndGame?: number;
55
96
  @Prop() playerColors?: string[];
97
+ @Prop({ required: true }) paymentTable!: number[];
98
+ @Prop({ default: false }) compact!: boolean;
99
+
100
+ houses: (Piece & { cityCount: number })[] = [];
56
101
 
57
- houses: Piece[] = [];
102
+ cellX(count: number) {
103
+ return 33 * (this.compact ? count % 11 : count);
104
+ }
105
+
106
+ cellY(count: number) {
107
+ return this.compact ? Math.floor(count / 11) * 70 : 0;
108
+ }
58
109
 
59
110
  createPieces(gameState: GameState) {
60
111
  this.houses = [];
@@ -63,18 +114,33 @@ export default class CityCount extends Vue {
63
114
 
64
115
  gameState.players.forEach((player, pi) => adjustCityCount[player.cities.length].push(pi));
65
116
  gameState.players.forEach((player, pi) => {
66
- let x = (adjustCityCount[player.cities.length].length == 1 ? 23 : 20) + 33 * player.cities.length;
67
- x += (adjustCityCount[player.cities.length].indexOf(pi) % 2) * 6;
117
+ // Players sharing a count get a grid, bottom-aligned in the cell so it grows
118
+ // upward from the income strip and leaves the digit above readable. At the
119
+ // old 6px/3px offsets even TWO players on the same number — the ordinary
120
+ // case all game — overlapped by half and read as one smudge.
121
+ //
122
+ // Five or six share go three wide rather than three tall: a third row starts
123
+ // at the top of the cell and hides the number completely, which is every
124
+ // game's opening position. Three columns only have to overlap by a few
125
+ // pixels, and the cells are spaced wider than they are drawn.
126
+ const sharing = adjustCityCount[player.cities.length];
127
+ const seat = sharing.indexOf(pi);
128
+ const cols = sharing.length > 4 ? 3 : 2;
129
+ const pitch = cols === 3 ? 9 : HOUSE_WIDTH;
130
+ const rows = Math.ceil(sharing.length / cols);
68
131
 
69
132
  this.houses.push({
70
133
  id: pi + '_cityCount',
71
- x: x,
72
- y:
73
- 15 +
74
- (adjustCityCount[player.cities.length].indexOf(pi) * 30) /
75
- adjustCityCount[player.cities.length].length,
134
+ cityCount: player.cities.length,
135
+ // CELL_CENTRE_X/BOTTOM are the darkgoldenrod cell drawn in the template.
136
+ x:
137
+ sharing.length == 1
138
+ ? CELL_CENTRE_X - HOUSE_WIDTH / 2
139
+ : CELL_CENTRE_X - ((cols - 1) * pitch + HOUSE_WIDTH) / 2 + (seat % cols) * pitch,
140
+ y: CELL_BOTTOM - rows * HOUSE_HEIGHT + Math.floor(seat / cols) * HOUSE_HEIGHT,
76
141
  color: this.playerColors![pi],
77
142
  owner: pi,
143
+ ownerName: player.name,
78
144
  });
79
145
  });
80
146
  }
@@ -193,6 +193,7 @@
193
193
  <circle
194
194
  v-if="city.connectionCost == null"
195
195
  :key="city.name + '_circle2'"
196
+ :data-tutorial="city.name === tutorialCity ? 'destination' : undefined"
196
197
  :class="[{ canClick: canBuild(city) || canPickRegion(city) }]"
197
198
  r="20"
198
199
  :cx="city.x"
@@ -460,6 +461,21 @@
460
461
  stroke-linecap="round"
461
462
  />
462
463
  </g>
464
+ <g v-if="guidedCity" pointer-events="none">
465
+ <circle :cx="guidedCity.x" :cy="guidedCity.y" r="29" fill="none" stroke="#a96600" stroke-width="4" />
466
+ <text
467
+ :x="guidedCity.x"
468
+ :y="guidedCity.y - 37"
469
+ text-anchor="middle"
470
+ fill="#233745"
471
+ stroke="#fffef5"
472
+ stroke-width="6"
473
+ paint-order="stroke"
474
+ font-size="19"
475
+ font-weight="600"
476
+ v-text="guidedCity.name"
477
+ />
478
+ </g>
463
479
  </g>
464
480
  </template>
465
481
 
@@ -481,6 +497,8 @@ const CITY_RING_RADIUS = 25;
481
497
  },
482
498
  })
483
499
  export default class Map extends Vue {
500
+ @Prop() tutorialCity?: string;
501
+ get guidedCity() { return this.cities?.find((city) => city.name === this.tutorialCity); }
484
502
  @Prop() polygons?: Polygon[];
485
503
  @Prop() cities?: City[];
486
504
  @Prop() connections?: Connection[];
@@ -553,6 +571,7 @@ export default class Map extends Vue {
553
571
  y: city.y + offsetY,
554
572
  color: this.playerColors![pi],
555
573
  owner: pi,
574
+ ownerName: player.name,
556
575
  scale: pieceScale,
557
576
  });
558
577
  });
@@ -22,7 +22,7 @@
22
22
  stroke-width="12"
23
23
  stroke-miterlimit="10"
24
24
  />
25
- <title>{{ ownerName }}'s House</title>
25
+ <title>{{ houseTitle }}</title>
26
26
  </g>
27
27
  </template>
28
28
  <script lang="ts">
@@ -50,5 +50,11 @@ export default class House extends Mixins(Piece) {
50
50
 
51
51
  @Prop()
52
52
  ownerName?: string;
53
+
54
+ get houseTitle() {
55
+ if (this.owner !== undefined && this.owner === this.player) return 'Your House';
56
+ if (this.ownerName) return `${this.ownerName}'s House`;
57
+ return this.owner === undefined ? 'House' : `Player ${this.owner + 1}'s House`;
58
+ }
53
59
  }
54
60
  </script>
package/src/game-chat.ts CHANGED
@@ -69,6 +69,32 @@ export function mountGameChat(emitter: ViewerEmitter<any, any>, host: Element):
69
69
  shortcutCount.className = 'chat-shortcut-count';
70
70
  shortcut.append(shortcutCount);
71
71
  (host.querySelector('.chat-tabs-host') || slot).append(shortcut);
72
+ function positionShortcut() {
73
+ shortcut.style.bottom = '';
74
+ if (shortcut.hidden) return;
75
+ const box = shortcut.getBoundingClientRect();
76
+ let top = box.top;
77
+ const controls = Array.from(host.querySelectorAll('#scene g.button'))
78
+ .map((control) => control.getBoundingClientRect())
79
+ .filter((control) => control.left < box.right && control.right > box.left)
80
+ .sort((a, b) => b.top - a.top);
81
+ for (const control of controls) {
82
+ if (control.top < top + box.height && control.bottom > top) top = control.top - box.height - 8;
83
+ }
84
+ if (top !== box.top) shortcut.style.bottom = `${window.innerHeight - top - box.height}px`;
85
+ }
86
+ let positionFrame: number | undefined;
87
+ function scheduleShortcutPosition() {
88
+ if (positionFrame !== undefined) return;
89
+ positionFrame = window.requestAnimationFrame(() => {
90
+ positionFrame = undefined;
91
+ positionShortcut();
92
+ });
93
+ }
94
+ window.addEventListener('scroll', scheduleShortcutPosition, { passive: true });
95
+ window.addEventListener('resize', scheduleShortcutPosition);
96
+ const layoutObserver = new ResizeObserver(scheduleShortcutPosition);
97
+ layoutObserver.observe(host);
72
98
  const feeds = host.querySelector<HTMLElement>('.journal-and-chat');
73
99
  const tabs = document.createElement('nav');
74
100
  tabs.className = 'game-feed-tabs';
@@ -114,6 +140,7 @@ export function mountGameChat(emitter: ViewerEmitter<any, any>, host: Element):
114
140
  shortcut.hidden = chatVisible || !count;
115
141
  shortcut.setAttribute('aria-label', `Open ${label}`);
116
142
  chatTab.textContent = label;
143
+ positionShortcut();
117
144
  }
118
145
  shortcut.onclick = () => {
119
146
  selectFeed('chat');
@@ -149,6 +176,10 @@ export function mountGameChat(emitter: ViewerEmitter<any, any>, host: Element):
149
176
  dispose.push(() => tabs.remove());
150
177
  return () => {
151
178
  dispose.forEach((cleanup) => cleanup());
179
+ window.removeEventListener('scroll', scheduleShortcutPosition);
180
+ window.removeEventListener('resize', scheduleShortcutPosition);
181
+ layoutObserver.disconnect();
182
+ if (positionFrame !== undefined) window.cancelAnimationFrame(positionFrame);
152
183
  view.destroy();
153
184
  shortcut.remove();
154
185
  style.remove();
@@ -192,6 +223,6 @@ export function installLocalChat(emitter: ChatEmitter): void {
192
223
  createdAt: new Date().toISOString(),
193
224
  },
194
225
  ]);
195
- emitter.emit('chat:result', { requestId, ok: true });
226
+ if (requestId) emitter.emit('chat:result', { requestId, ok: true });
196
227
  });
197
228
  }
package/src/launch.ts CHANGED
@@ -10,6 +10,11 @@ import { shouldAdoptLogState } from './util/turn-buffer';
10
10
 
11
11
  let dispose: (() => void) | undefined;
12
12
 
13
+ export function destroyViewer() {
14
+ dispose?.();
15
+ dispose = undefined;
16
+ }
17
+
13
18
  function launch(selector: string) {
14
19
  const target = document.querySelector(selector);
15
20
  if (!target) throw new Error(`Viewer mount point not found: ${selector}`);
package/src/sounds.ts CHANGED
@@ -102,7 +102,7 @@ export function installActionSounds(emitter: Pick<ViewerEmitter<any, any>, 'on'>
102
102
  let previous: string[] | undefined;
103
103
  let replaying = false;
104
104
  emitter.on('update:preference', (pref) => {
105
- if (pref?.name === 'sound') {
105
+ if (pref?.name === 'sound' && typeof pref.value === 'boolean') {
106
106
  setSoundEnabled(pref.value);
107
107
  }
108
108
  });