powergrid-viewer 2.0.14 → 2.1.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.
@@ -0,0 +1,309 @@
1
+ import { playerColors } from 'powergrid-engine/src/gamestate';
2
+ type ChatMessage = { _id?: string; author?: string; text: string; createdAt?: string; playerIndex?: number };
3
+ type ChatEmitter = {
4
+ on: (event: string, fn: (data: any) => void) => unknown;
5
+ emit: (event: string, data: any) => unknown;
6
+ };
7
+ export function mountGameChat(emitter: ChatEmitter, host: Element): void {
8
+ const panel = document.createElement('details');
9
+ panel.className = 'bgs-game-chat';
10
+ panel.open = true;
11
+ panel.innerHTML =
12
+ '<summary>Chat</summary><div class="chat-messages" role="log" aria-label="Game chat"></div><div class="chat-composer"><input type="text" aria-label="Chat message" placeholder="Message…" autocomplete="off"><button type="button">Send</button></div><div class="chat-status" role="status"></div>';
13
+ const style = document.createElement('style');
14
+ style.textContent = `
15
+ .bgs-game-chat{box-sizing:border-box;font:14px/1.4 Arial,sans-serif;border:1px solid #a2aa82;border-radius:3px;margin:8px 0;padding:8px 12px;background:#f3f0dc;color:#263521}
16
+ .bgs-game-chat summary{cursor:pointer;font-weight:650;border-radius:3px;width:fit-content;padding:2px 4px;margin:-2px -4px}
17
+ .bgs-game-chat summary:hover{color:#126778}
18
+ .bgs-game-chat summary:focus-visible,.bgs-game-chat button:focus-visible{outline:2px solid #247d8c;outline-offset:3px}
19
+ .bgs-game-chat .chat-messages{max-height:250px;overflow:auto;overscroll-behavior:auto;margin:6px 0 8px}
20
+ .bgs-game-chat article{padding:5px 0;border-bottom:1px solid #75818d26;white-space:pre-wrap;overflow-wrap:anywhere}
21
+ .bgs-game-chat article strong{padding:0 3px;font-weight:bold}
22
+ .bgs-game-chat article:last-child{border-bottom:0}
23
+ .bgs-game-chat time{font-size:12px;color:#536e77;margin-left:8px;white-space:nowrap}
24
+ .bgs-game-chat .chat-composer{display:flex;gap:6px;align-items:center;margin:0}
25
+ .bgs-game-chat input{flex:1;min-width:0;box-sizing:border-box;height:30px;background:#f3f5eb;color:#263521;border:1px solid #899997;border-radius:2px;padding:4px 7px;font:14px Arial,sans-serif}
26
+ .bgs-game-chat input::placeholder{color:#637477}
27
+ .bgs-game-chat input:focus{outline:2px solid #527f89;outline-offset:1px}
28
+ .bgs-game-chat button{box-sizing:border-box;height:30px;cursor:pointer;border:1px solid #7f8c8d;border-radius:3px;background:#dddeda;color:#172d34;padding:3px 12px;font:14px Arial,sans-serif}
29
+ .bgs-game-chat button:hover:not(:disabled){background:#c8d3d0}
30
+ .bgs-game-chat button:disabled{color:#788786;border-color:#b0bcb8;background:#dce3df;cursor:default}
31
+ .bgs-game-chat .chat-status{font-size:12px;margin-top:6px}
32
+ .bgs-game-chat .chat-status:empty{display:none}
33
+ .chat-shortcut{position:fixed;left:16px;bottom:max(16px,env(safe-area-inset-bottom));z-index:900;padding:7px 12px;border:1px solid #6a8589;border-radius:3px;background:#263521;color:#fff;font:600 14px Arial,sans-serif;cursor:pointer;box-shadow:0 2px 6px #0003}
34
+ .chat-shortcut[hidden]{display:none}
35
+ .chat-shortcut:hover{background:#315966}
36
+ .chat-shortcut:focus-visible{outline:2px solid #fff;outline-offset:2px}
37
+
38
+ `;
39
+ panel.append(style);
40
+ const slot = host.querySelector('.chat-host');
41
+ if (slot) slot.append(panel);
42
+ else host.insertAdjacentElement('afterend', panel);
43
+ const list = panel.querySelector('.chat-messages') as HTMLDivElement;
44
+ const input = panel.querySelector('input') as HTMLInputElement;
45
+ const button = panel.querySelector('button') as HTMLButtonElement;
46
+ const status = panel.querySelector('.chat-status') as HTMLDivElement;
47
+ let messages: ChatMessage[] = [];
48
+ let players: { id: number; name: string; color?: string }[] = [];
49
+ let localPlayer: number | undefined;
50
+ emitter.on('state', (state) => {
51
+ players = state?.players || [];
52
+ render();
53
+ });
54
+ emitter.on('player', (player) => {
55
+ localPlayer = player?.index;
56
+ render();
57
+ });
58
+ let canSend = false;
59
+ let disabled = false;
60
+ let reason = 'Chat is connecting…';
61
+ let pending: { id: string; text: string } | undefined;
62
+ let timeout: ReturnType<typeof setTimeout> | undefined;
63
+ let readTimer: ReturnType<typeof setTimeout> | undefined;
64
+ let watermark = '';
65
+ let candidate = '';
66
+ let following = true;
67
+ const unread = new Set<string>();
68
+ const summary = panel.querySelector('summary')!;
69
+ const shortcut = document.createElement('button');
70
+ shortcut.type = 'button';
71
+ shortcut.className = 'chat-shortcut';
72
+ shortcut.hidden = true;
73
+ panel.insertAdjacentElement('afterend', shortcut);
74
+ let chatVisible = false;
75
+ function updateShortcut(): void {
76
+ const count = unread.size;
77
+ const label = count ? `Chat · ${count} unread` : 'Chat';
78
+ shortcut.textContent = label;
79
+ shortcut.setAttribute('aria-label', `Open ${label}`);
80
+ summary.textContent = label;
81
+ shortcut.hidden = chatVisible;
82
+ }
83
+ shortcut.onclick = () => {
84
+ panel.open = true;
85
+ requestAnimationFrame(() => {
86
+ const firstUnread = Array.from(list.children).find((row) =>
87
+ unread.has((row as HTMLElement).dataset.id || '')
88
+ );
89
+ if (firstUnread) firstUnread.scrollIntoView({ block: 'center' });
90
+ else panel.scrollIntoView({ block: 'center' });
91
+ summary.focus({ preventScroll: true });
92
+ read();
93
+ });
94
+ };
95
+ const visibility = new IntersectionObserver(
96
+ (entries) => {
97
+ for (const entry of entries) {
98
+ if (entry.target === panel) {
99
+ chatVisible =
100
+ entry.isIntersecting &&
101
+ entry.intersectionRect.height >=
102
+ Math.min(panel.open ? 80 : 20, entry.boundingClientRect.height);
103
+ }
104
+ }
105
+ updateShortcut();
106
+ read();
107
+ },
108
+ { threshold: Array.from({ length: 21 }, (_, i) => i / 20) }
109
+ );
110
+ visibility.observe(panel);
111
+ function controls(): void {
112
+ button.disabled = !canSend || disabled || !!pending || !input.value.trim();
113
+ input.disabled = !canSend || disabled;
114
+ }
115
+ function read(): void {
116
+ if (!panel.open || document.visibilityState !== 'visible' || !document.hasFocus()) {
117
+ return;
118
+ }
119
+ const bounds = list.getBoundingClientRect();
120
+ const visible = Array.from(list.children).filter((el) => {
121
+ const r = el.getBoundingClientRect();
122
+ return r.bottom <= Math.min(bounds.bottom, window.innerHeight) + 1 && r.top >= Math.max(bounds.top, 0);
123
+ });
124
+ for (const row of visible) unread.delete((row as HTMLElement).dataset.id || '');
125
+ updateShortcut();
126
+ const id = (visible[visible.length - 1] as HTMLElement)?.dataset.id || '';
127
+ if (!/^[a-f0-9]{24}$/i.test(id) || id.toLowerCase() <= watermark) {
128
+ return;
129
+ }
130
+ candidate = candidate > id.toLowerCase() ? candidate : id.toLowerCase();
131
+ if (!readTimer) {
132
+ readTimer = setTimeout(() => {
133
+ readTimer = undefined;
134
+ if (
135
+ panel.open &&
136
+ document.visibilityState === 'visible' &&
137
+ document.hasFocus() &&
138
+ candidate > watermark
139
+ ) {
140
+ watermark = candidate;
141
+ emitter.emit('chat:read', { messageId: watermark });
142
+ }
143
+ }, 500);
144
+ }
145
+ }
146
+ function render(): void {
147
+ list.textContent = '';
148
+ for (const message of messages) {
149
+ const row = document.createElement('article');
150
+ row.dataset.id = message._id || '';
151
+ const author = document.createElement('strong');
152
+ author.textContent = message.author || 'Game';
153
+ const index =
154
+ message.playerIndex ??
155
+ (message.author === 'You' ? localPlayer : players.find((p) => p.name === message.author)?.id);
156
+ if (index !== undefined && playerColors[index]) {
157
+ author.style.backgroundColor = players.find((p) => p.id === index)?.color || playerColors[index];
158
+ author.style.color = author.style.backgroundColor === 'brown' ? '#fff' : '#111';
159
+ }
160
+ row.append(author, document.createTextNode(' '), document.createTextNode(message.text));
161
+ if (message.createdAt) {
162
+ const time = document.createElement('time');
163
+ const date = new Date(message.createdAt);
164
+ if (!isNaN(date.getTime())) {
165
+ time.textContent = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
166
+ time.title = date.toLocaleString();
167
+ row.append(time);
168
+ }
169
+ }
170
+
171
+ list.append(row);
172
+ }
173
+ if (following) {
174
+ list.scrollTop = list.scrollHeight;
175
+ }
176
+ updateShortcut();
177
+ read();
178
+ }
179
+ emitter.on('chat:messages', (data: ChatMessage[]) => {
180
+ messages = data || [];
181
+ unread.clear();
182
+ render();
183
+ });
184
+ emitter.on('chat:appended', (data: ChatMessage[]) => {
185
+ for (const message of data || []) {
186
+ if (!message._id || !messages.some((m) => m._id === message._id)) {
187
+ messages.push(message);
188
+ const own = message.playerIndex !== undefined && message.playerIndex === localPlayer;
189
+ if (message._id && !own && message.author !== 'You') unread.add(message._id);
190
+ }
191
+ }
192
+ render();
193
+ });
194
+ emitter.on('chat:updated', (data: ChatMessage[]) => {
195
+ for (const message of data || []) {
196
+ const index = messages.findIndex((m) => m._id === message._id);
197
+ if (index >= 0) {
198
+ messages[index] = message;
199
+ }
200
+ }
201
+ render();
202
+ });
203
+ emitter.on('chat:deleted', (ids: string[]) => {
204
+ messages = messages.filter((m) => !ids.includes(m._id || ''));
205
+ ids.forEach((id) => unread.delete(id));
206
+ render();
207
+ });
208
+ emitter.on('chat:disabled', (value: boolean) => {
209
+ disabled = value;
210
+ status.textContent = value ? 'Chat disabled' : reason;
211
+ controls();
212
+ });
213
+ emitter.on('chat:state', (value: { canSend: boolean; reason?: string }) => {
214
+ canSend = value.canSend;
215
+ reason = canSend
216
+ ? ''
217
+ : (
218
+ {
219
+ 'not-logged-in': 'Sign in to chat',
220
+ 'not-confirmed': 'Confirm your account to chat',
221
+ 'not-a-player': 'Only players can send messages',
222
+ 'chat-disabled': 'Chat disabled',
223
+ 'no-game': 'Chat will be available when the game starts',
224
+ } as Record<string, string>
225
+ )[value.reason || ''] || 'Chat is read-only';
226
+ status.textContent = reason;
227
+ controls();
228
+ });
229
+ emitter.on('chat:result', (result: { requestId: string; ok: boolean; error?: string }) => {
230
+ if (pending?.id !== result.requestId) {
231
+ return;
232
+ }
233
+ if (timeout) {
234
+ clearTimeout(timeout);
235
+ }
236
+ if (result.ok && input.value === pending.text) {
237
+ input.value = '';
238
+ }
239
+ status.textContent = result.ok ? '' : result.error || 'Message could not be sent. Your draft is kept.';
240
+ pending = undefined;
241
+ controls();
242
+ });
243
+ function sendMessage(): void {
244
+ if (button.disabled) {
245
+ return;
246
+ }
247
+ pending = { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, text: input.value };
248
+ controls();
249
+ status.textContent = 'Sending…';
250
+ const sending = pending;
251
+ timeout = setTimeout(() => {
252
+ if (pending) {
253
+ pending = undefined;
254
+ status.textContent = 'No confirmation received. Check the conversation before sending again.';
255
+ controls();
256
+ }
257
+ }, 20000);
258
+ emitter.emit('chat:send', { text: sending.text.trim(), requestId: sending.id });
259
+ }
260
+ button.onclick = sendMessage;
261
+ input.oninput = controls;
262
+ input.onkeydown = (event) => {
263
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
264
+ event.preventDefault();
265
+ event.stopPropagation();
266
+ sendMessage();
267
+ }
268
+ };
269
+ list.onscroll = () => {
270
+ following = list.scrollHeight - list.scrollTop - list.clientHeight < 32;
271
+ read();
272
+ };
273
+ panel.ontoggle = () => {
274
+ if (panel.open && following) {
275
+ list.scrollTop = list.scrollHeight;
276
+ }
277
+ read();
278
+ };
279
+ window.addEventListener('scroll', read, { passive: true });
280
+ window.addEventListener('focus', read);
281
+ window.addEventListener('resize', read);
282
+ const sizing = new ResizeObserver(() => {
283
+ updateShortcut();
284
+ read();
285
+ });
286
+ sizing.observe(panel);
287
+ document.addEventListener('visibilitychange', read);
288
+ status.textContent = reason;
289
+ controls();
290
+ }
291
+
292
+ export function installLocalChat(emitter: ChatEmitter): void {
293
+ emitter.emit('chat:state', { canSend: true });
294
+ emitter.emit('chat:messages', [
295
+ {
296
+ _id: '000000000000000000000001',
297
+ author: 'Playtest',
298
+ text: 'Local chat preview. Messages stay in this browser.',
299
+ createdAt: new Date().toISOString(),
300
+ },
301
+ ]);
302
+ let next = 2;
303
+ emitter.on('chat:send', ({ text, requestId }) => {
304
+ emitter.emit('chat:appended', [
305
+ { _id: (next++).toString(16).padStart(24, '0'), author: 'You', text, createdAt: new Date().toISOString() },
306
+ ]);
307
+ emitter.emit('chat:result', { requestId, ok: true });
308
+ });
309
+ }
package/src/launch.ts CHANGED
@@ -2,6 +2,8 @@ import { EventEmitter } from 'events';
2
2
  import type { GameState, Move } from 'powergrid-engine';
3
3
  import Vue from 'vue';
4
4
  import Game from './components/Game.vue';
5
+ import { mountGameChat } from './game-chat';
6
+ import { installActionSounds } from './sounds';
5
7
  import type { Preferences } from './types/ui-data';
6
8
  import { shouldAdoptLogState } from './util/turn-buffer';
7
9
 
@@ -100,6 +102,8 @@ function launch(selector: string) {
100
102
  item.emit('fetchState');
101
103
  });
102
104
 
105
+ installActionSounds(item);
106
+ mountGameChat(item, app.$el);
103
107
  return item;
104
108
  }
105
109
 
@@ -2,7 +2,9 @@ import { cloneDeep } from 'lodash';
2
2
  import { move as execMove, Move, Phase, setup, stripSecret } from 'powergrid-engine';
3
3
  import { moveAI } from 'powergrid-engine/src/engine';
4
4
  import type { MapName, Variant } from 'powergrid-engine/src/gamestate';
5
+ import { installLocalChat } from './game-chat';
5
6
  import launch from './launch';
7
+ import { mountSoundTests } from './sounds';
6
8
 
7
9
  const delayBase = 0;
8
10
 
@@ -10,6 +12,8 @@ function launchSelfContained(selector = '#app') {
10
12
  const strip = true;
11
13
 
12
14
  const emitter = launch(selector);
15
+ mountSoundTests(emitter);
16
+ installLocalChat(emitter);
13
17
 
14
18
  // The sandbox game can be steered from the URL so a layout or rules change can
15
19
  // be checked against several maps without editing this file each time, e.g.
package/src/sounds.ts ADDED
@@ -0,0 +1,187 @@
1
+ type Note = [number, number, number, number, number?];
2
+ type Cue = { label: string; notes: Note[] };
3
+ export const soundCues: Record<string, Cue> = {
4
+ build: {
5
+ label: 'Connect a city',
6
+ notes: [
7
+ [0, 0.1, 0, 0.12, 1500],
8
+ [0.08, 0.25, 95, 0.1, 45],
9
+ [0.18, 0.14, 360, 0.05, 500],
10
+ ],
11
+ },
12
+ fuel: {
13
+ label: 'Buy fuel',
14
+ notes: [
15
+ [0, 0.12, 0, 0.1, 1100],
16
+ [0.1, 0.13, 0, 0.08, 600],
17
+ [0.12, 0.16, 120, 0.06, 65],
18
+ ],
19
+ },
20
+ bid: { label: 'Auction bid', notes: [[0, 0.08, 400, 0.07, 550]] },
21
+ plant: {
22
+ label: 'Power plant',
23
+ notes: [
24
+ [0, 0.15, 110, 0.09],
25
+ [0.07, 0.23, 220, 0.07, 330],
26
+ ],
27
+ },
28
+ power: {
29
+ label: 'Generate electricity',
30
+ notes: [
31
+ [0, 0.45, 60, 0.09, 120],
32
+ [0.05, 0.4, 121, 0.05, 240],
33
+ [0.1, 0.35, 0, 0.04, 1300],
34
+ ],
35
+ },
36
+ };
37
+ let context: AudioContext | undefined;
38
+ let enabled = true;
39
+ export function setSoundEnabled(value: boolean): void {
40
+ enabled = value;
41
+ }
42
+ export function playSound(name: string): void {
43
+ if (!enabled || !soundCues[name] || typeof window === 'undefined') {
44
+ return;
45
+ }
46
+ if (typeof navigator !== 'undefined') {
47
+ const activation = (navigator as Navigator & { userActivation?: { hasBeenActive: boolean } }).userActivation;
48
+ if (activation && !activation.hasBeenActive) {
49
+ return;
50
+ }
51
+ }
52
+ const Audio = window.AudioContext;
53
+ if (!Audio) {
54
+ return;
55
+ }
56
+ context = context || new Audio();
57
+ const ctx = context;
58
+ void ctx
59
+ .resume()
60
+ .then(() => {
61
+ if (!enabled || ctx.state !== 'running') {
62
+ return;
63
+ }
64
+ for (const [offset, duration, frequency, volume, endFrequency] of soundCues[name].notes) {
65
+ const start = ctx.currentTime + offset;
66
+ const gain = ctx.createGain();
67
+ gain.gain.setValueAtTime(0.0001, start);
68
+ gain.gain.exponentialRampToValueAtTime(volume, start + 0.008);
69
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
70
+ gain.connect(ctx.destination);
71
+ if (frequency === 0) {
72
+ const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * duration), ctx.sampleRate);
73
+ const samples = buffer.getChannelData(0);
74
+ for (let i = 0; i < samples.length; i++) {
75
+ samples[i] = Math.random() * 2 - 1;
76
+ }
77
+ const source = ctx.createBufferSource();
78
+ source.buffer = buffer;
79
+ const filter = ctx.createBiquadFilter();
80
+ filter.type = 'lowpass';
81
+ filter.frequency.value = endFrequency || 900;
82
+ source.connect(filter);
83
+ filter.connect(gain);
84
+ source.start(start);
85
+ source.stop(start + duration);
86
+ } else {
87
+ const oscillator = ctx.createOscillator();
88
+ oscillator.type = 'sine';
89
+ oscillator.frequency.setValueAtTime(frequency, start);
90
+ oscillator.frequency.exponentialRampToValueAtTime(endFrequency || frequency, start + duration);
91
+ oscillator.connect(gain);
92
+ oscillator.start(start);
93
+ oscillator.stop(start + duration);
94
+ }
95
+ }
96
+ })
97
+ .catch(() => undefined);
98
+ }
99
+
100
+ export function installActionSounds(emitter: { on: (event: string, fn: (value: any) => void) => unknown }): void {
101
+ let previous: string[] | undefined;
102
+ let replaying = false;
103
+ emitter.on('update:preference', (pref) => {
104
+ if (pref?.name === 'sound') {
105
+ setSoundEnabled(pref.value);
106
+ }
107
+ });
108
+ emitter.on('preferences', (prefs) => {
109
+ if (typeof prefs?.sound === 'boolean') {
110
+ setSoundEnabled(prefs.sound);
111
+ }
112
+ });
113
+ emitter.on('replay:start', () => {
114
+ replaying = true;
115
+ });
116
+ emitter.on('replay:end', () => {
117
+ replaying = false;
118
+ previous = undefined;
119
+ });
120
+ const receiveState = (state: any) => {
121
+ const entries = state?.log || [];
122
+ const next = entries.map((entry: any) => JSON.stringify(entry));
123
+ const extendsHistory =
124
+ previous && previous.length < next.length && previous.every((entry, i) => entry === next[i]);
125
+ const from = previous?.length || 0;
126
+ previous = next;
127
+ if (!extendsHistory || replaying) {
128
+ return;
129
+ }
130
+ const cues = entries
131
+ .slice(from)
132
+ .map((entry: any) => cueForEntry(entry))
133
+ .filter(Boolean);
134
+ // Reconnection can deliver a whole round: play only the most recent event.
135
+ const cue = cues[cues.length - 1];
136
+ if (cue) {
137
+ playSound(cue);
138
+ }
139
+ };
140
+ emitter.on('state', receiveState);
141
+ emitter.on('gamelog', (event) => {
142
+ if (event?.data?.state) {
143
+ receiveState(event.data.state);
144
+ }
145
+ });
146
+ }
147
+
148
+ export function mountSoundTests(emitter: { emit: (event: string, value: any) => unknown }): void {
149
+ const panel = document.createElement('details');
150
+ panel.style.cssText =
151
+ 'position:relative;z-index:5;padding:10px 16px;margin:8px;background:#172638;color:#f0f4f8;border:1px solid #56718a;border-radius:8px;font:14px system-ui';
152
+ const summary = document.createElement('summary');
153
+ summary.textContent = 'Playtest tools · sounds';
154
+ panel.append(summary);
155
+ const label = document.createElement('label');
156
+ label.style.margin = '10px';
157
+ const toggle = document.createElement('input');
158
+ toggle.type = 'checkbox';
159
+ toggle.checked = true;
160
+ toggle.onchange = () => {
161
+ setSoundEnabled(toggle.checked);
162
+ emitter.emit('preferences', { sound: toggle.checked });
163
+ };
164
+ label.append(toggle, ' Game sounds');
165
+ panel.append(label);
166
+ for (const [name, cue] of Object.entries(soundCues)) {
167
+ const button = document.createElement('button');
168
+ button.type = 'button';
169
+ button.textContent = cue.label;
170
+ button.style.cssText =
171
+ 'margin:8px 4px;padding:7px 12px;color:#f0f4f8;background:#294663;border:1px solid #7391ad;border-radius:5px;cursor:pointer';
172
+ button.onclick = () => playSound(name);
173
+ panel.append(button);
174
+ }
175
+ document.body.prepend(panel);
176
+ }
177
+ function cueForEntry(entry: any): string | undefined {
178
+ return (
179
+ {
180
+ Build: 'build',
181
+ BuyResource: 'fuel',
182
+ Bid: 'bid',
183
+ ChoosePowerPlant: 'plant',
184
+ UsePowerPlant: 'power',
185
+ } as Record<string, string>
186
+ )[entry.move?.name];
187
+ }