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.
@@ -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>
@@ -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
@@ -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';
@@ -10,6 +11,11 @@ import { shouldAdoptLogState } from './util/turn-buffer';
10
11
 
11
12
  let dispose: (() => void) | undefined;
12
13
 
14
+ export function destroyViewer() {
15
+ dispose?.();
16
+ dispose = undefined;
17
+ }
18
+
13
19
  function launch(selector: string) {
14
20
  const target = document.querySelector(selector);
15
21
  if (!target) throw new Error(`Viewer mount point not found: ${selector}`);
@@ -46,7 +52,7 @@ function launch(selector: string) {
46
52
  }).$mount(mountPoint);
47
53
 
48
54
  let replaying = false;
49
- const viewer = createViewer<GameState, Move[]>({
55
+ const viewer = createViewer<GameState, Move[] | PremoveCommand>({
50
56
  async onState(data) {
51
57
  params.state = data;
52
58
  app.$forceUpdate();
@@ -92,6 +98,7 @@ function launch(selector: string) {
92
98
  });
93
99
  const item = viewer.emitter;
94
100
  params.emitter.on('move', (moves: Move[]) => viewer.move(moves));
101
+ params.emitter.on('premoves', (command: PremoveCommand) => viewer.move(command));
95
102
  params.emitter.on('fetchState', () => viewer.fetchState());
96
103
  params.emitter.on('addLog', (data: string[]) => viewer.addLog(data));
97
104
  params.emitter.on('replaceLog', (data: string[]) => viewer.replaceLog(data));
@@ -1,7 +1,8 @@
1
1
  import { cloneDeep } from 'lodash';
2
- import { move as execMove, Move, Phase, setup, stripSecret } from 'powergrid-engine';
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 | 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
- let newState = cloneDeep(gameState);
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 = moveAI(
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 = moveAI(
115
+ gameState = wrapperAI(
118
116
  gameState,
119
117
  gameState.players.findIndex((pl) => pl.isAI && pl.availableMoves)
120
118
  );
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
  });