powergrid-viewer 2.1.2 → 2.1.4

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.2",
3
+ "version": "2.1.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/boardgamers/powergrid.git",
@@ -11,13 +11,14 @@
11
11
  "url": "https://github.com/boardgamers/powergrid/issues"
12
12
  },
13
13
  "dependencies": {
14
+ "@boardgamers/protocol": "0.2.2",
14
15
  "core-js": "^3.6.5",
15
16
  "lodash": "^4.17.19",
16
17
  "regenerator-runtime": "^0.13.7",
17
18
  "tslib": "^2.2.0",
18
19
  "vue": "^2.6.11",
19
20
  "vue-class-component": "^7.2.3",
20
- "vue-property-decorator": "^8.4.2",
21
+ "vue-property-decorator": "^9.1.2",
21
22
  "powergrid-engine": "2.0.6"
22
23
  },
23
24
  "devDependencies": {
@@ -35,9 +36,10 @@
35
36
  "chai": "^4.1.2",
36
37
  "eslint": "^7.28.0",
37
38
  "eslint-plugin-vue": "^7.11.0",
39
+ "playwright": "1.62.1",
38
40
  "sass": "^1.26.5",
39
41
  "sass-loader": "^8.0.2",
40
- "typescript": "^4.3.5",
42
+ "typescript": "^5.9.3",
41
43
  "vue-template-compiler": "^2.6.11"
42
44
  },
43
45
  "files": [
@@ -53,6 +55,7 @@
53
55
  "serve": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve",
54
56
  "build": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",
55
57
  "test:unit": "vue-cli-service test:unit",
56
- "package": "vue-cli-service build --target lib --name powergrid-viewer src/wrapper.ts"
58
+ "package": "vue-cli-service build --target lib --name powergrid-viewer src/wrapper.ts",
59
+ "test:browser": "node scripts/protocol-smoke.mjs"
57
60
  }
58
61
  }
@@ -414,6 +414,7 @@
414
414
  </g>
415
415
  </svg>
416
416
 
417
+ <div class="chat-tabs-host"></div>
417
418
  <div class="journal-and-chat">
418
419
  <InlineLog v-if="G" :entries="logReversed.slice().reverse()" :mapName="G.options.map" />
419
420
  <div class="chat-host"></div>
@@ -2022,7 +2023,7 @@ export default class Game extends Vue {
2022
2023
 
2023
2024
  if (!this.G || this.G.log.length == 1) {
2024
2025
  return 'Game Start!';
2025
- } else if (this.G.currentPlayers == []) {
2026
+ } else if (this.G.currentPlayers.length === 0) {
2026
2027
  return 'Game ended!';
2027
2028
  } else if (this.player !== undefined && this.G?.currentPlayers.includes(this.player)) {
2028
2029
  const currentPlayer = this.G.players[this.player];
package/src/game-chat.ts CHANGED
@@ -1,21 +1,22 @@
1
+ import { ChatController, ChatMessage, chatSegments } from '@boardgamers/protocol/chat';
2
+ import { mountChat } from '@boardgamers/protocol/chat/dom';
3
+ import { attachChat, ViewerEmitter } from '@boardgamers/protocol/viewer';
1
4
  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>';
5
+ type ChatEmitter = Pick<ViewerEmitter<any, any>, 'on' | 'emit'>;
6
+ export function mountGameChat(emitter: ViewerEmitter<any, any>, host: Element): () => void {
7
+ const chat = new ChatController();
8
+ const detach = attachChat(emitter, chat);
9
+ const slot = host.querySelector<HTMLElement>('.chat-host') || document.createElement('div');
10
+ if (!slot.parentElement) host.insertAdjacentElement('afterend', slot);
13
11
  const style = document.createElement('style');
14
12
  style.textContent = `
15
13
  .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
14
  .bgs-game-chat summary{cursor:pointer;font-weight:650;border-radius:3px;width:fit-content;padding:2px 4px;margin:-2px -4px}
17
15
  .bgs-game-chat summary:hover{color:#126778}
18
16
  .bgs-game-chat summary:focus-visible,.bgs-game-chat button:focus-visible{outline:2px solid #247d8c;outline-offset:3px}
17
+ .bgs-game-chat .chat-day{display:flex;align-items:center;gap:8px;margin:12px 0 6px;font-size:.85em;opacity:.8}
18
+ .bgs-game-chat .chat-day::before,.bgs-game-chat .chat-day::after{content:"";flex:1;border-top:1px solid currentColor;opacity:.3}
19
+ .bgs-game-chat .chat-day time{margin:0;color:inherit;font-size:inherit}
19
20
  .bgs-game-chat .chat-messages{max-height:250px;overflow:auto;overscroll-behavior:auto;margin:6px 0 8px}
20
21
  .bgs-game-chat article{padding:5px 0;border-bottom:1px solid #75818d26;white-space:pre-wrap;overflow-wrap:anywhere}
21
22
  .bgs-game-chat article strong{padding:0 3px;font-weight:bold}
@@ -28,10 +29,17 @@ export function mountGameChat(emitter: ChatEmitter, host: Element): void {
28
29
  .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
30
  .bgs-game-chat button:hover:not(:disabled){background:#c8d3d0}
30
31
  .bgs-game-chat button:disabled{color:#788786;border-color:#b0bcb8;background:#dce3df;cursor:default}
32
+ .bgs-game-chat .chat-mention{height:auto;padding:0 2px;border:0;background:transparent;color:inherit;font:inherit;font-weight:bold;text-decoration:underline}
33
+ .bgs-game-chat article a{color:inherit;text-decoration:underline}
34
+ .chat-suggestions{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
35
+ .chat-suggestions:empty{display:none}
36
+ .chat-suggestions button[aria-pressed="true"]{outline:2px solid #527f89}
31
37
  .bgs-game-chat .chat-status{font-size:12px;margin-top:6px}
32
38
  .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}
39
+ .chat-shortcut{position:fixed;right:89px;bottom:max(17px,env(safe-area-inset-bottom));z-index:900;display:inline-flex;align-items:center;gap:7px;box-sizing:border-box;height:40px;padding:0 10px 0 13px;border:1px solid #6a8589;border-radius:20px;background:#263521;color:#fff;font:600 14px Arial,sans-serif;cursor:pointer;box-shadow:0 2px 6px #0003}
34
40
  .chat-shortcut[hidden]{display:none}
41
+ .chat-shortcut svg{flex:none}
42
+ .chat-shortcut-count{box-sizing:border-box;min-width:22px;height:22px;padding:0 6px;border-radius:11px;background:#f2c230;color:#172d34;font:700 12px/22px Arial,sans-serif;text-align:center}
35
43
  .game-feed-tabs{display:none}
36
44
  @media(max-width:700px){
37
45
  .game-feed-tabs{display:flex;gap:4px;margin-top:8px}
@@ -44,292 +52,128 @@ export function mountGameChat(emitter: ChatEmitter, host: Element): void {
44
52
  .chat-shortcut:focus-visible{outline:2px solid #fff;outline-offset:2px}
45
53
 
46
54
  `;
47
- panel.append(style);
48
- const slot = host.querySelector('.chat-host');
49
- if (slot) slot.append(panel);
50
- else host.insertAdjacentElement('afterend', panel);
51
- const feeds = host.querySelector('.journal-and-chat') as HTMLElement | null;
55
+ slot.append(style);
56
+ let players: { id: number; name: string; color?: string; faction?: string }[] = [];
57
+ let localPlayer: number | undefined;
58
+ let chatVisible = false;
59
+ const shortcut = document.createElement('button');
60
+ shortcut.type = 'button';
61
+ shortcut.className = 'chat-shortcut';
62
+ shortcut.hidden = true;
63
+ // Bottom-right, just left of the platform's settings gear (fixed at right:25px, 45px wide):
64
+ // the slot the platform's own chat button used. Shown only for unread messages, since the
65
+ // feed itself is always reachable below the board.
66
+ shortcut.innerHTML =
67
+ '<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M3.5 1.5h9A1.5 1.5 0 0 1 14 3v6.5A1.5 1.5 0 0 1 12.5 11H6.6l-3.3 2.8a.5.5 0 0 1-.8-.4V11A1.5 1.5 0 0 1 2 9.5V3a1.5 1.5 0 0 1 1.5-1.5z"/></svg><span>Chat</span>';
68
+ const shortcutCount = document.createElement('span');
69
+ shortcutCount.className = 'chat-shortcut-count';
70
+ shortcut.append(shortcutCount);
71
+ (host.querySelector('.chat-tabs-host') || slot).append(shortcut);
72
+ const feeds = host.querySelector<HTMLElement>('.journal-and-chat');
52
73
  const tabs = document.createElement('nav');
53
74
  tabs.className = 'game-feed-tabs';
54
75
  tabs.setAttribute('aria-label', 'Chat and journal');
55
76
  const chatTab = document.createElement('button');
56
77
  const journalTab = document.createElement('button');
57
78
  chatTab.type = journalTab.type = 'button';
58
- chatTab.textContent = 'Chat';
59
79
  journalTab.textContent = 'Journal';
60
80
  tabs.append(chatTab, journalTab);
61
- if (feeds) feeds.insertAdjacentElement('beforebegin', tabs);
62
- function selectFeed(feed: 'chat' | 'journal'): void {
81
+ host.querySelector('.chat-tabs-host')?.append(tabs);
82
+ function selectFeed(feed: 'chat' | 'journal') {
63
83
  if (feeds) feeds.dataset.feed = feed;
64
84
  chatTab.setAttribute('aria-pressed', String(feed === 'chat'));
65
85
  journalTab.setAttribute('aria-pressed', String(feed === 'journal'));
66
86
  }
67
87
  selectFeed('chat');
68
- chatTab.onclick = () => {
69
- selectFeed('chat');
70
- panel.open = true;
71
- requestAnimationFrame(read);
72
- };
73
- journalTab.onclick = () => selectFeed('journal');
74
- const list = panel.querySelector('.chat-messages') as HTMLDivElement;
75
- const input = panel.querySelector('input') as HTMLInputElement;
76
- const button = panel.querySelector('button') as HTMLButtonElement;
77
- const status = panel.querySelector('.chat-status') as HTMLDivElement;
78
- let messages: ChatMessage[] = [];
79
- let players: { id: number; name: string; color?: string }[] = [];
80
- let localPlayer: number | undefined;
81
- emitter.on('state', (state) => {
82
- players = state?.players || [];
83
- render();
84
- });
85
- emitter.on('player', (player) => {
86
- localPlayer = player?.index;
87
- render();
88
+ const view = mountChat(slot, {
89
+ chat,
90
+ styles: false,
91
+ openPlayer: (index) => emitter.emit('player:clicked', { index }),
92
+ renderAuthor,
93
+ onVisibilityChange(visible) {
94
+ chatVisible = visible;
95
+ updateShortcut();
96
+ },
88
97
  });
89
- let canSend = false;
90
- let disabled = false;
91
- let reason = 'Chat is connecting…';
92
- let pending: { id: string; text: string } | undefined;
93
- let timeout: ReturnType<typeof setTimeout> | undefined;
94
- let readTimer: ReturnType<typeof setTimeout> | undefined;
95
- let watermark = '';
96
- let candidate = '';
97
- let following = true;
98
- const unread = new Set<string>();
99
- const summary = panel.querySelector('summary')!;
100
- const shortcut = document.createElement('button');
101
- shortcut.type = 'button';
102
- shortcut.className = 'chat-shortcut';
103
- shortcut.hidden = true;
104
- (feeds || panel).insertAdjacentElement('afterend', shortcut);
105
- let chatVisible = false;
106
- function updateShortcut(): void {
107
- const count = unread.size;
98
+ function renderAuthor(message: ChatMessage): Node {
99
+ const author = document.createElement('strong');
100
+ author.textContent = message.author || 'Game';
101
+ const index =
102
+ message.playerIndex ??
103
+ (message.author === 'You' ? localPlayer : players.find((p) => p.name === message.author)?.id);
104
+ if (index !== undefined && playerColors[index]) {
105
+ author.style.backgroundColor = players.find((p) => p.id === index)?.color || playerColors[index];
106
+ author.style.color = author.style.backgroundColor === 'brown' ? '#fff' : '#111';
107
+ }
108
+ return author;
109
+ }
110
+ function updateShortcut() {
111
+ const count = chat.unread;
108
112
  const label = count ? `Chat · ${count} unread` : 'Chat';
109
- shortcut.textContent = label;
113
+ shortcutCount.textContent = String(count);
114
+ shortcut.hidden = chatVisible || !count;
110
115
  shortcut.setAttribute('aria-label', `Open ${label}`);
111
- summary.textContent = label;
112
116
  chatTab.textContent = label;
113
- shortcut.hidden = chatVisible;
114
117
  }
115
118
  shortcut.onclick = () => {
116
119
  selectFeed('chat');
117
- panel.open = true;
118
- requestAnimationFrame(() => {
119
- const firstUnread = Array.from(list.children).find((row) =>
120
- unread.has((row as HTMLElement).dataset.id || '')
121
- );
122
- if (firstUnread) firstUnread.scrollIntoView({ block: 'center' });
123
- else panel.scrollIntoView({ block: 'center' });
124
- summary.focus({ preventScroll: true });
125
- read();
126
- });
120
+ view.open();
127
121
  };
128
- const visibility = new IntersectionObserver(
129
- (entries) => {
130
- for (const entry of entries) {
131
- if (entry.target === panel) {
132
- chatVisible =
133
- entry.isIntersecting &&
134
- entry.intersectionRect.height >=
135
- Math.min(panel.open ? 80 : 20, entry.boundingClientRect.height);
136
- }
137
- }
138
- updateShortcut();
139
- read();
140
- },
141
- { threshold: Array.from({ length: 21 }, (_, i) => i / 20) }
142
- );
143
- visibility.observe(panel);
144
- function controls(): void {
145
- button.disabled = !canSend || disabled || !!pending || !input.value.trim();
146
- input.disabled = !canSend || disabled;
147
- }
148
- function read(): void {
149
- if (!panel.open || document.visibilityState !== 'visible' || !document.hasFocus()) {
150
- return;
151
- }
152
- const bounds = list.getBoundingClientRect();
153
- if (!bounds.width || !bounds.height) return;
154
- const visible = Array.from(list.children).filter((el) => {
155
- const r = el.getBoundingClientRect();
156
- return r.bottom <= Math.min(bounds.bottom, window.innerHeight) + 1 && r.top >= Math.max(bounds.top, 0);
157
- });
158
- for (const row of visible) unread.delete((row as HTMLElement).dataset.id || '');
159
- updateShortcut();
160
- const id = (visible[visible.length - 1] as HTMLElement)?.dataset.id || '';
161
- if (!/^[a-f0-9]{24}$/i.test(id) || id.toLowerCase() <= watermark) {
162
- return;
163
- }
164
- candidate = candidate > id.toLowerCase() ? candidate : id.toLowerCase();
165
- if (!readTimer) {
166
- readTimer = setTimeout(() => {
167
- readTimer = undefined;
168
- if (
169
- panel.open &&
170
- document.visibilityState === 'visible' &&
171
- document.hasFocus() &&
172
- candidate > watermark
173
- ) {
174
- watermark = candidate;
175
- emitter.emit('chat:read', { messageId: watermark });
176
- }
177
- }, 500);
178
- }
179
- }
180
- function render(): void {
181
- list.textContent = '';
182
- for (const message of messages) {
183
- const row = document.createElement('article');
184
- row.dataset.id = message._id || '';
185
- const author = document.createElement('strong');
186
- author.textContent = message.author || 'Game';
187
- const index =
188
- message.playerIndex ??
189
- (message.author === 'You' ? localPlayer : players.find((p) => p.name === message.author)?.id);
190
- if (index !== undefined && playerColors[index]) {
191
- author.style.backgroundColor = players.find((p) => p.id === index)?.color || playerColors[index];
192
- author.style.color = author.style.backgroundColor === 'brown' ? '#fff' : '#111';
193
- }
194
- row.append(author, document.createTextNode(' '), document.createTextNode(message.text));
195
- if (message.createdAt) {
196
- const time = document.createElement('time');
197
- const date = new Date(message.createdAt);
198
- if (!isNaN(date.getTime())) {
199
- time.textContent = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
200
- time.title = date.toLocaleString();
201
- row.append(time);
202
- }
122
+ const dispose = [
123
+ detach,
124
+ chat.subscribe(updateShortcut),
125
+ emitter.on('state', (state) => {
126
+ players = (state?.players || []).map((player: any, index: number) => ({ ...player, id: index }));
127
+ view.refresh();
128
+ }),
129
+ emitter.on('gamelog', ({ data }: any) => {
130
+ if (data?.state) {
131
+ players = (data.state.players || []).map((player: any, index: number) => ({ ...player, id: index }));
132
+ view.refresh();
203
133
  }
204
-
205
- list.append(row);
206
- }
207
- if (following) {
208
- list.scrollTop = list.scrollHeight;
209
- }
210
- updateShortcut();
211
- read();
212
- }
213
- emitter.on('chat:messages', (data: ChatMessage[]) => {
214
- messages = data || [];
215
- unread.clear();
216
- render();
217
- });
218
- emitter.on('chat:appended', (data: ChatMessage[]) => {
219
- for (const message of data || []) {
220
- if (!message._id || !messages.some((m) => m._id === message._id)) {
221
- messages.push(message);
222
- const own = message.playerIndex !== undefined && message.playerIndex === localPlayer;
223
- if (message._id && !own && message.author !== 'You') unread.add(message._id);
224
- }
225
- }
226
- render();
227
- });
228
- emitter.on('chat:updated', (data: ChatMessage[]) => {
229
- for (const message of data || []) {
230
- const index = messages.findIndex((m) => m._id === message._id);
231
- if (index >= 0) {
232
- messages[index] = message;
233
- }
234
- }
235
- render();
236
- });
237
- emitter.on('chat:deleted', (ids: string[]) => {
238
- messages = messages.filter((m) => !ids.includes(m._id || ''));
239
- ids.forEach((id) => unread.delete(id));
240
- render();
241
- });
242
- emitter.on('chat:disabled', (value: boolean) => {
243
- disabled = value;
244
- status.textContent = value ? 'Chat disabled' : reason;
245
- controls();
246
- });
247
- emitter.on('chat:state', (value: { canSend: boolean; reason?: string }) => {
248
- canSend = value.canSend;
249
- reason = canSend
250
- ? ''
251
- : (
252
- {
253
- 'not-logged-in': 'Sign in to chat',
254
- 'not-confirmed': 'Confirm your account to chat',
255
- 'not-a-player': 'Only players can send messages',
256
- 'chat-disabled': 'Chat disabled',
257
- 'no-game': 'Chat will be available when the game starts',
258
- } as Record<string, string>
259
- )[value.reason || ''] || 'Chat is read-only';
260
- status.textContent = reason;
261
- controls();
262
- });
263
- emitter.on('chat:result', (result: { requestId: string; ok: boolean; error?: string }) => {
264
- if (pending?.id !== result.requestId) {
265
- return;
266
- }
267
- if (timeout) {
268
- clearTimeout(timeout);
269
- }
270
- if (result.ok && input.value === pending.text) {
271
- input.value = '';
272
- }
273
- status.textContent = result.ok ? '' : result.error || 'Message could not be sent. Your draft is kept.';
274
- pending = undefined;
275
- controls();
276
- });
277
- function sendMessage(): void {
278
- if (button.disabled) {
279
- return;
280
- }
281
- pending = { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, text: input.value };
282
- controls();
283
- status.textContent = 'Sending…';
284
- const sending = pending;
285
- timeout = setTimeout(() => {
286
- if (pending) {
287
- pending = undefined;
288
- status.textContent = 'No confirmation received. Check the conversation before sending again.';
289
- controls();
290
- }
291
- }, 20000);
292
- emitter.emit('chat:send', { text: sending.text.trim(), requestId: sending.id });
293
- }
294
- button.onclick = sendMessage;
295
- input.oninput = controls;
296
- input.onkeydown = (event) => {
297
- if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
298
- event.preventDefault();
299
- event.stopPropagation();
300
- sendMessage();
301
- }
134
+ }),
135
+ emitter.on('player', (player) => {
136
+ localPlayer = player?.index;
137
+ view.refresh();
138
+ }),
139
+ ];
140
+ chatTab.onclick = () => {
141
+ selectFeed('chat');
142
+ chat.setOpen(true);
143
+ view.refresh();
302
144
  };
303
- list.onscroll = () => {
304
- if (list.clientHeight) following = list.scrollHeight - list.scrollTop - list.clientHeight < 32;
305
- read();
145
+ journalTab.onclick = () => {
146
+ selectFeed('journal');
147
+ view.refresh();
306
148
  };
307
- panel.ontoggle = () => {
308
- if (panel.open && following) {
309
- list.scrollTop = list.scrollHeight;
310
- }
311
- read();
149
+ dispose.push(() => tabs.remove());
150
+ return () => {
151
+ dispose.forEach((cleanup) => cleanup());
152
+ view.destroy();
153
+ shortcut.remove();
154
+ style.remove();
312
155
  };
313
- window.addEventListener('scroll', read, { passive: true });
314
- window.addEventListener('focus', read);
315
- window.addEventListener('resize', read);
316
- const sizing = new ResizeObserver(() => {
317
- if (following && list.clientHeight) list.scrollTop = list.scrollHeight;
318
- updateShortcut();
319
- read();
320
- });
321
- sizing.observe(panel);
322
- sizing.observe(list);
323
- document.addEventListener('visibilitychange', read);
324
- status.textContent = reason;
325
- controls();
326
156
  }
327
157
 
328
158
  export function installLocalChat(emitter: ChatEmitter): void {
159
+ let seat = 0;
160
+ let roster: { id: string; name: string; playerIndex: number }[] = [];
161
+ emitter.on('player', (player) => {
162
+ seat = player.index ?? 0;
163
+ });
164
+ emitter.on('state', (state) => {
165
+ roster = (state?.players || []).map((player: any, index: number) => ({
166
+ id: String(index),
167
+ name: player.name || `Player ${index + 1}`,
168
+ playerIndex: index,
169
+ }));
170
+ emitter.emit('chat:state', { canSend: true, mentions: roster.filter((player) => player.playerIndex !== seat) });
171
+ });
329
172
  emitter.emit('chat:state', { canSend: true });
330
173
  emitter.emit('chat:messages', [
331
174
  {
332
175
  _id: '000000000000000000000001',
176
+ type: 'system',
333
177
  author: 'Playtest',
334
178
  text: 'Local chat preview. Messages stay in this browser.',
335
179
  createdAt: new Date().toISOString(),
@@ -338,7 +182,15 @@ export function installLocalChat(emitter: ChatEmitter): void {
338
182
  let next = 2;
339
183
  emitter.on('chat:send', ({ text, requestId }) => {
340
184
  emitter.emit('chat:appended', [
341
- { _id: (next++).toString(16).padStart(24, '0'), author: 'You', text, createdAt: new Date().toISOString() },
185
+ {
186
+ _id: (next++).toString(16).padStart(24, '0'),
187
+ type: 'text',
188
+ author: 'You',
189
+ playerIndex: seat,
190
+ text,
191
+ segments: chatSegments(text, new Map(roster.map((player) => [player.name, player.id])), true),
192
+ createdAt: new Date().toISOString(),
193
+ },
342
194
  ]);
343
195
  emitter.emit('chat:result', { requestId, ok: true });
344
196
  });