fuzzyworld

word-waltz / files / src / main.ts

main.ts

36.8 KB · history · blame

1/**
2 * main.ts — application entry point
3 *
4 * Responsibilities:
5 * - Parse the URL fragment to extract gameId and encryption key
6 * - Route to the correct view: game creation, word picking, or Wordle solving
7 * - Handle all game-state writes (encrypt → Firestore)
8 * - Subscribe to real-time Firestore updates and re-render on change
9 */
10
11import { parseLink } from './utils/shareLink.js';
12import { generateKey, exportKey, importKey, encrypt, decrypt } from './crypto/encryption.js';
13import { createGame, getGame, updateGame, subscribeToGame, deleteGame, storePushSubscription } from './firebase/gameService.js';
14import { getStoredGames, upsertStoredGame, removeStoredGame, clearStoredGames } from './utils/gameRegistry.js';
15import { buildLink } from './utils/shareLink.js';
16import { mountWordPicker } from './components/WordPicker.js';
17import { mountWordleBoard } from './components/WordleBoard.js';
18import { mountScoreboard } from './components/Scoreboard.js';
19import { mountSoloScoreboard } from './components/SoloScoreboard.js';
20import { mountShareDialog } from './components/ShareDialog.js';
21import { notifyMyTurn, clearNotification, registerServiceWorker, subscribeToPush, isNotifOptedOut, setNotifOptOut, unsubscribeFromPush } from './utils/notifications.js';
22import speakerIconUrl from './assets/speaker.png';
23import muteIconUrl from './assets/mute.png';
24import urlIconUrl from './assets/url.png';
25import { getRandomWord, isValidAnswer } from './utils/wordList.js';
26import type { GameState, PlayerId, Round, SoloGameState, SoloRound, PendingNotification } from './types.js';
27// Waiting status helper
28// ---------------------------------------------------------------------------
29
30function createWaitingEl(text: string): HTMLElement {
31 const wrap = document.createElement('div');
32 wrap.className = 'status status--waiting';
33 const dot = document.createElement('span');
34 dot.className = 'status__live-dot';
35 dot.setAttribute('aria-hidden', 'true');
36 const msg = document.createElement('span');
37 msg.textContent = text;
38 wrap.appendChild(dot);
39 wrap.appendChild(msg);
40 return wrap;
41}
42
43// ---------------------------------------------------------------------------
44// Player identity — stored in localStorage, keyed to the gameId.
45//
46// Limitation: if a player opens the link on a new device they won't have a
47// stored role and will be assigned based on game state heuristics (see below).
48// ---------------------------------------------------------------------------
49
50const roleKey = (gameId: string) => `word-waltz:player:${gameId}`;
51const NAME_KEY = 'word-waltz:name';
52
53function getSavedName(): string { return localStorage.getItem(NAME_KEY) ?? ''; }
54function saveName(name: string): void { localStorage.setItem(NAME_KEY, name); }
55
56function getRole(gameId: string): PlayerId | null {
57 return localStorage.getItem(roleKey(gameId)) as PlayerId | null;
58}
59
60function setRole(gameId: string, role: PlayerId): void {
61 localStorage.setItem(roleKey(gameId), role);
62}
63
64// ---------------------------------------------------------------------------
65// State-machine helpers
66// ---------------------------------------------------------------------------
67
68/** The player who needs to pick a word for the next round. */
69function nextChallenger(state: GameState): PlayerId {
70 if (state.rounds.length === 0) return 'a';
71 const last = state.rounds[state.rounds.length - 1];
72 return last.challenger === 'a' ? 'b' : 'a';
73}
74
75/** The player who is solving the current round. */
76function currentSolver(state: GameState): PlayerId {
77 return state.rounds[state.currentRound].challenger === 'a' ? 'b' : 'a';
78}
79
80/** Returns true if it is currently this player's turn to act. */
81function isMyTurn(state: GameState, role: PlayerId): boolean {
82 if (state.players.b.name === '') return false; // waiting for opponent to join
83 if (state.turn === 'pick') return nextChallenger(state) === role;
84 return currentSolver(state) === role;
85}
86
87/** Human-readable status shown on the home screen game list. */
88function computeStatus(state: GameState, role: PlayerId): string {
89 if (state.players.b.name === '') return 'Waiting for opponent to join';
90 if (isMyTurn(state, role)) {
91 return state.turn === 'pick' ? 'Your turn to pick a word' : 'Your turn to solve';
92 }
93 return state.turn === 'pick' ? 'Opponent is picking a word' : 'Opponent is solving';
94}
95
96/** Navigate to a game by setting the URL fragment (triggers hashchange). */
97function navigateToGame(gameId: string, key: string): void {
98 const fragment = buildLink(gameId, key).split('#')[1];
99 if (fragment) window.location.hash = fragment;
100}
101
102// ---------------------------------------------------------------------------
103// Share button — shown in the header while a game is active
104// ---------------------------------------------------------------------------
105
106let shareModal: HTMLElement | null = null;
107
108function updateShareButton(): void {
109 const btn = document.getElementById('share-btn') as HTMLButtonElement | null;
110 if (!btn) return;
111
112 const { gameId, key } = parseLink();
113 if (!gameId || !key) {
114 btn.hidden = true;
115 btn.onclick = null;
116 return;
117 }
118
119 const inviteUrl = buildLink(gameId, key, 'b');
120
121 if (!btn.hidden && btn.querySelector('img')) {
122 // Already set up — just refresh the onclick with the current URL
123 btn.onclick = openShareModal(inviteUrl);
124 return;
125 }
126
127 btn.textContent = '';
128 const shareImg = document.createElement('img');
129 shareImg.src = urlIconUrl;
130 shareImg.alt = 'Share';
131 btn.appendChild(shareImg);
132 btn.hidden = false;
133 btn.onclick = openShareModal(inviteUrl);
134}
135
136function openShareModal(inviteUrl: string): () => void {
137 return () => {
138 if (shareModal) { shareModal.remove(); shareModal = null; return; }
139
140 const overlay = document.createElement('div');
141 overlay.className = 'share-modal';
142 shareModal = overlay;
143
144 const box = document.createElement('div');
145 box.className = 'share-modal__box';
146
147 const closeBtn = document.createElement('button');
148 closeBtn.className = 'share-modal__close';
149 closeBtn.textContent = '×';
150 closeBtn.setAttribute('aria-label', 'Close');
151 closeBtn.onclick = () => { overlay.remove(); shareModal = null; };
152 box.appendChild(closeBtn);
153
154 const inner = document.createElement('div');
155 box.appendChild(inner);
156 overlay.appendChild(box);
157 document.body.appendChild(overlay);
158
159 import('./components/ShareDialog.js').then(({ mountShareDialog }) => {
160 mountShareDialog(inner, { shareUrl: inviteUrl });
161 }).catch(console.error);
162
163 overlay.addEventListener('click', (e) => {
164 if (e.target === overlay) { overlay.remove(); shareModal = null; }
165 });
166 };
167}
168
169// ---------------------------------------------------------------------------
170// Firestore write helper
171// ---------------------------------------------------------------------------
172
173async function saveState(
174 gameId: string,
175 key: CryptoKey,
176 state: GameState,
177 pendingNotification?: PendingNotification | null,
178): Promise<void> {
179 const envelope = await encrypt(JSON.stringify(state), key);
180 await updateGame(gameId, envelope, pendingNotification);
181}
182
183// ---------------------------------------------------------------------------
184// View rendering
185// ---------------------------------------------------------------------------
186
187let cleanupView: (() => void) | null = null;
188let unsubscribeGame: (() => void) | null = null;
189
190function render(
191 app: HTMLElement,
192 state: GameState,
193 myRole: PlayerId,
194 gameId: string,
195 key: CryptoKey,
196 base64urlKey: string,
197 shareUrl: string,
198): void {
199 cleanupView?.();
200 cleanupView = null;
201 app.innerHTML = '';
202
203 // Keep the home-screen game list up to date with the latest state.
204 upsertStoredGame({
205 gameId,
206 key: base64urlKey,
207 role: myRole,
208 myName: state.players[myRole].name,
209 opponentName: (myRole === 'a' ? state.players.b : state.players.a).name,
210 status: computeStatus(state, myRole),
211 myTurn: isMyTurn(state, myRole),
212 createdAt: state.createdAt,
213 lastUpdated: Date.now(),
214 });
215
216 // Scoreboard is always visible
217 const scoreboardEl = document.createElement('div');
218 app.appendChild(scoreboardEl);
219 const cleanupScoreboard = mountScoreboard(scoreboardEl, {
220 players: state.players,
221 rounds: state.rounds,
222 myRole,
223 turn: state.turn,
224 currentRound: state.currentRound,
225 onNameChange: async (newName: string) => {
226 saveName(newName);
227 await saveState(gameId, key, {
228 ...state,
229 players: {
230 ...state.players,
231 [myRole]: { ...state.players[myRole], name: newName },
232 },
233 updatedAt: Date.now(),
234 });
235 },
236 });
237
238 const mainEl = document.createElement('div');
239 mainEl.className = 'main-view';
240 app.appendChild(mainEl);
241
242 let cleanupMain: (() => void) | null = null;
243
244 // Before the opponent has joined, always show the share dialog so Player A
245 // can send the invite link first. Once Player B sets their name (received
246 // via real-time subscription) the view re-renders into the normal flow.
247 if (state.players.b.name === '') {
248 const shareEl = document.createElement('div');
249 mainEl.appendChild(shareEl);
250 const inviteUrl = buildLink(gameId, base64urlKey, 'b');
251 cleanupMain = mountShareDialog(shareEl, { shareUrl: inviteUrl });
252 mainEl.appendChild(createWaitingEl('Share the link above with your opponent, then wait for them to join…'));
253 } else if (state.turn === 'pick') {
254 const challenger = nextChallenger(state);
255
256 if (challenger === myRole) {
257 const solver: PlayerId = myRole === 'a' ? 'b' : 'a';
258 cleanupMain = mountWordPicker(mainEl, {
259 onWordChosen: async (word: string) => {
260 const newRound: Round = {
261 challenger: myRole,
262 word,
263 guesses: [],
264 solved: false,
265 score: 0,
266 };
267 const notify: PendingNotification = {
268 player: solver,
269 message: `${state.players[myRole].name || 'Your opponent'} challenged you — your turn to solve!`,
270 gameUrl: shareUrl.split('#')[0],
271 };
272 await saveState(gameId, key, {
273 ...state,
274 rounds: [...state.rounds, newRound],
275 currentRound: state.rounds.length,
276 turn: 'solve',
277 updatedAt: Date.now(),
278 }, notify);
279 },
280 });
281 } else {
282 mainEl.appendChild(createWaitingEl('Waiting for your opponent to pick a word…'));
283 }
284 } else {
285 // turn === 'solve'
286 const solver = currentSolver(state);
287
288 if (solver === myRole) {
289 const round = state.rounds[state.currentRound];
290 if (!round) {
291 // currentRound is temporarily out of bounds (e.g. between write
292 // and the real-time subscription delivering the new round).
293 mainEl.innerHTML = '<p class="status">Loading…</p>';
294 cleanupView = () => { cleanupScoreboard(); };
295 return;
296 }
297 cleanupMain = mountWordleBoard(mainEl, {
298 secretWord: round.word,
299 onRoundEnd: async (result) => {
300 const updatedRounds = state.rounds.map((r, i) =>
301 i === state.currentRound ? { ...r, ...result } : r,
302 );
303 await saveState(gameId, key, {
304 ...state,
305 rounds: updatedRounds,
306 players: {
307 ...state.players,
308 [myRole]: {
309 ...state.players[myRole],
310 score: state.players[myRole].score + result.score,
311 },
312 },
313 turn: 'pick',
314 updatedAt: Date.now(),
315 });
316 },
317 });
318 } else {
319 // Challenger waits while their opponent solves
320 mainEl.appendChild(createWaitingEl('Waiting for your opponent to solve the word…'));
321 }
322 }
323
324 cleanupView = () => {
325 cleanupScoreboard();
326 cleanupMain?.();
327 };
328}
329
330// ---------------------------------------------------------------------------
331// Solo mode — purely local, no Firestore
332// ---------------------------------------------------------------------------
333
334const SOLO_KEY = 'word-waltz:solo';
335
336function loadSoloGame(): SoloGameState | null {
337 try {
338 const raw = localStorage.getItem(SOLO_KEY);
339 if (!raw) return null;
340 const state = JSON.parse(raw) as SoloGameState;
341 // Discard games created with an old word list
342 const currentWord = state.rounds[state.currentRound]?.word;
343 if (!currentWord || !isValidAnswer(currentWord)) {
344 localStorage.removeItem(SOLO_KEY);
345 return null;
346 }
347 return state;
348 } catch {
349 return null;
350 }
351}
352
353function saveSoloGame(state: SoloGameState): void {
354 localStorage.setItem(SOLO_KEY, JSON.stringify(state));
355}
356
357function deleteSoloGame(): void {
358 localStorage.removeItem(SOLO_KEY);
359}
360
361function showSoloGame(app: HTMLElement): void {
362 unsubscribeGame?.();
363 unsubscribeGame = null;
364 cleanupView?.();
365 cleanupView = null;
366 app.innerHTML = '';
367
368 let state = loadSoloGame();
369 if (!state) {
370 const name = getSavedName();
371 const firstWord = getRandomWord();
372 const firstRound: SoloRound = { word: firstWord, guesses: [], solved: false, score: 0 };
373 state = {
374 playerName: name || 'You',
375 totalScore: 0,
376 rounds: [firstRound],
377 currentRound: 0,
378 createdAt: Date.now(),
379 updatedAt: Date.now(),
380 };
381 saveSoloGame(state);
382 }
383
384 function renderSolo(current: SoloGameState): void {
385 cleanupView?.();
386 cleanupView = null;
387 app.innerHTML = '';
388
389 const scoreboardEl = document.createElement('div');
390 app.appendChild(scoreboardEl);
391 const cleanupScoreboard = mountSoloScoreboard(scoreboardEl, {
392 playerName: current.playerName,
393 totalScore: current.totalScore,
394 rounds: current.rounds,
395 onNameChange: (newName: string) => {
396 saveName(newName);
397 const updated = { ...current, playerName: newName, updatedAt: Date.now() };
398 saveSoloGame(updated);
399 renderSolo(updated);
400 },
401 });
402
403 const mainEl = document.createElement('div');
404 mainEl.className = 'main-view';
405 app.appendChild(mainEl);
406
407 const round = current.rounds[current.currentRound];
408
409 const cleanupBoard = mountWordleBoard(mainEl, {
410 secretWord: round.word,
411 onRoundEnd: (result) => {
412 const updatedRounds: SoloRound[] = current.rounds.map((r, i) =>
413 i === current.currentRound ? { ...r, ...result } : r,
414 );
415 const newTotalScore = current.totalScore + result.score;
416 const nextWord = getRandomWord();
417 const nextRound: SoloRound = { word: nextWord, guesses: [], solved: false, score: 0 };
418 const updated: SoloGameState = {
419 ...current,
420 totalScore: newTotalScore,
421 rounds: [...updatedRounds, nextRound],
422 currentRound: updatedRounds.length,
423 updatedAt: Date.now(),
424 };
425 saveSoloGame(updated);
426 renderSolo(updated);
427 },
428 });
429
430 cleanupView = () => {
431 cleanupScoreboard();
432 cleanupBoard();
433 };
434 }
435
436 renderSolo(state);
437}
438
439// ---------------------------------------------------------------------------
440// Home screen — game list + new game form
441// ---------------------------------------------------------------------------
442
443function showHomeScreen(app: HTMLElement): void {
444 app.innerHTML = '';
445
446 const root = document.createElement('div');
447 root.className = 'home-screen';
448 app.appendChild(root);
449
450 // --- Game list (multiplayer + solo merged) ---
451 const games = getStoredGames();
452 const existingSolo = loadSoloGame();
453 const hasAnything = games.length > 0 || existingSolo !== null;
454
455 if (hasAnything) {
456 const listSection = document.createElement('div');
457 listSection.className = 'game-list';
458
459 const header = document.createElement('div');
460 header.className = 'game-list__header';
461
462 const h2 = document.createElement('h2');
463 h2.textContent = 'Your Games';
464 header.appendChild(h2);
465
466 const deleteAllBtn = document.createElement('button');
467 deleteAllBtn.className = 'game-list__delete-all';
468 deleteAllBtn.textContent = 'Delete all';
469 deleteAllBtn.addEventListener('click', () => {
470 if (!confirm('Delete all games? This cannot be undone.')) return;
471 const all = getStoredGames();
472 clearStoredGames();
473 deleteSoloGame();
474 for (const g of all) {
475 deleteGame(g.gameId).catch((err: unknown) =>
476 console.warn('Firestore delete failed for', g.gameId, err));
477 }
478 showHomeScreen(app);
479 });
480 header.appendChild(deleteAllBtn);
481 listSection.appendChild(header);
482
483 const ul = document.createElement('ul');
484 ul.className = 'game-list__items';
485
486 // --- Solo card (always first if it exists) ---
487 if (existingSolo) {
488 const completedCount = existingSolo.rounds.filter(r => r.guesses.length > 0).length;
489 const li = document.createElement('li');
490 li.className = 'game-card game-card--solo';
491
492 const body = document.createElement('div');
493 body.className = 'game-card__body';
494 body.setAttribute('role', 'button');
495 body.tabIndex = 0;
496 body.addEventListener('click', () => showSoloGame(app));
497 body.addEventListener('keydown', (e) => {
498 if (e.key === 'Enter' || e.key === ' ') showSoloGame(app);
499 });
500
501 const labelEl = document.createElement('span');
502 labelEl.className = 'game-card__opponent';
503 labelEl.textContent = 'Solo';
504 body.appendChild(labelEl);
505
506 const statusEl = document.createElement('span');
507 statusEl.className = 'game-card__status game-card__status--my-turn';
508 statusEl.textContent = completedCount === 0
509 ? 'New game — your turn'
510 : `Score: ${existingSolo.totalScore} · ${completedCount} round${completedCount === 1 ? '' : 's'}`;
511 body.appendChild(statusEl);
512
513 const delBtn = document.createElement('button');
514 delBtn.className = 'game-card__delete';
515 delBtn.type = 'button';
516 delBtn.setAttribute('aria-label', 'Delete solo game');
517 delBtn.textContent = '×';
518 delBtn.addEventListener('click', () => {
519 deleteSoloGame();
520 li.remove();
521 if (getStoredGames().length === 0) showHomeScreen(app);
522 });
523
524 li.appendChild(body);
525 li.appendChild(delBtn);
526 ul.appendChild(li);
527 }
528
529 for (const game of games) {
530 const li = document.createElement('li');
531 li.className = 'game-card';
532
533 const body = document.createElement('div');
534 body.className = 'game-card__body';
535 body.setAttribute('role', 'button');
536 body.tabIndex = 0;
537 body.addEventListener('click', () => navigateToGame(game.gameId, game.key));
538 body.addEventListener('keydown', (e) => {
539 if (e.key === 'Enter' || e.key === ' ') navigateToGame(game.gameId, game.key);
540 });
541
542 const opponentEl = document.createElement('span');
543 opponentEl.className = 'game-card__opponent';
544 opponentEl.textContent = game.opponentName ? `vs. ${game.opponentName}` : 'Waiting for opponent';
545 body.appendChild(opponentEl);
546
547 const statusEl = document.createElement('span');
548 statusEl.className = 'game-card__status' + (game.myTurn ? ' game-card__status--my-turn' : '');
549 statusEl.textContent = game.status;
550 body.appendChild(statusEl);
551
552 const delBtn = document.createElement('button');
553 delBtn.className = 'game-card__delete';
554 delBtn.type = 'button';
555 delBtn.setAttribute('aria-label', 'Delete game');
556 delBtn.textContent = '×';
557 delBtn.addEventListener('click', () => {
558 removeStoredGame(game.gameId);
559 deleteGame(game.gameId).catch((err: unknown) =>
560 console.warn('Firestore delete failed for', game.gameId, err));
561 li.remove();
562 if (getStoredGames().length === 0 && !loadSoloGame()) showHomeScreen(app);
563 });
564
565 li.appendChild(body);
566 li.appendChild(delBtn);
567 ul.appendChild(li);
568 }
569
570 listSection.appendChild(ul);
571 root.appendChild(listSection);
572 }
573
574 // --- New game form ---
575 const newGameSection = document.createElement('div');
576 newGameSection.className = 'new-game';
577
578 const desc = document.createElement('p');
579 desc.textContent = 'Start a new game and share the link with a friend.';
580 newGameSection.appendChild(desc);
581
582 const form = document.createElement('form');
583 const label = document.createElement('label');
584 label.appendChild(document.createTextNode('Your name'));
585 const nameInput = document.createElement('input');
586 nameInput.type = 'text';
587 nameInput.maxLength = 20;
588 nameInput.required = true;
589 nameInput.autocomplete = 'off';
590 nameInput.value = getSavedName();
591 label.appendChild(nameInput);
592 form.appendChild(label);
593
594 const submitBtn = document.createElement('button');
595 submitBtn.type = 'submit';
596 submitBtn.textContent = 'Create game';
597 form.appendChild(submitBtn);
598 newGameSection.appendChild(form);
599
600 const orSolo = document.createElement('p');
601 orSolo.className = 'new-game__or';
602 orSolo.textContent = 'or';
603 newGameSection.appendChild(orSolo);
604
605 const soloBtn = document.createElement('button');
606 soloBtn.type = 'button';
607 soloBtn.className = 'new-game__solo-btn';
608 soloBtn.textContent = 'Play Solo';
609 soloBtn.addEventListener('click', () => showSoloGame(app));
610 newGameSection.appendChild(soloBtn);
611
612 root.appendChild(newGameSection);
613
614 form.addEventListener('submit', (e) => {
615 e.preventDefault();
616 const name = nameInput.value.trim();
617 if (!name) return;
618
619 saveName(name);
620
621 // Step 2: pick a word before creating the game
622 newGameSection.innerHTML = '';
623 const pickDesc = document.createElement('p');
624 pickDesc.textContent = 'Pick a secret word to challenge your opponent with:';
625 newGameSection.appendChild(pickDesc);
626
627 const wordPickerEl = document.createElement('div');
628 newGameSection.appendChild(wordPickerEl);
629
630 mountWordPicker(wordPickerEl, {
631 onWordChosen: async (word: string) => {
632 wordPickerEl.innerHTML = '<p class="status">Creating game…</p>';
633
634 try {
635 const gameId = crypto.randomUUID();
636 const cryptoKey = await generateKey();
637 const base64urlKey = await exportKey(cryptoKey);
638
639 const now = Date.now();
640 const firstRound: Round = {
641 challenger: 'a',
642 word,
643 guesses: [],
644 solved: false,
645 score: 0,
646 };
647 const initialState: GameState = {
648 players: {
649 a: { name, score: 0 },
650 b: { name: '', score: 0 },
651 },
652 rounds: [firstRound],
653 currentRound: 0,
654 turn: 'solve',
655 createdAt: now,
656 updatedAt: now,
657 };
658
659 const envelope = await encrypt(JSON.stringify(initialState), cryptoKey);
660
661 // Race the Firestore write against an 8 s timeout so we surface
662 // errors (e.g. database not created, network issue) instead of
663 // hanging on "Creating game…" forever.
664 const timeout = new Promise<never>((_, reject) =>
665 setTimeout(() => reject(new Error('Timed out connecting to Firestore. Make sure your Firestore security rules allow read/write (see the Rules tab in the Firebase console).')), 8_000),
666 );
667 await Promise.race([createGame(gameId, envelope), timeout]);
668
669 setRole(gameId, 'a');
670 upsertStoredGame({
671 gameId,
672 key: base64urlKey,
673 role: 'a',
674 myName: name,
675 opponentName: '',
676 status: 'Waiting for opponent to join',
677 myTurn: false,
678 createdAt: initialState.createdAt,
679 lastUpdated: Date.now(),
680 });
681
682 // Navigate — key is in the fragment and is never sent to the server
683 navigateToGame(gameId, base64urlKey);
684 } catch (err) {
685 console.error('Game creation failed:', err);
686 const raw = err instanceof Error ? err.message : String(err);
687 const msg = raw.toLowerCase().includes('permission')
688 ? 'Permission denied by Firestore. Open the Firebase console → Firestore → Rules and set: allow read, write: if true; then publish.'
689 : raw || 'Failed to create game. Please try again.';
690 showError(app, msg);
691 }
692 },
693 });
694 });
695}
696
697// ---------------------------------------------------------------------------
698// Player B name prompt
699// ---------------------------------------------------------------------------
700
701function promptPlayerBName(
702 app: HTMLElement,
703 onName: (name: string) => void,
704): void {
705 app.innerHTML = '';
706 const wrap = document.createElement('div');
707 wrap.className = 'new-game';
708 const p = document.createElement('p');
709 p.textContent = "You've been invited to play! Enter your name to join.";
710 wrap.appendChild(p);
711 const form = document.createElement('form');
712 const label = document.createElement('label');
713 label.appendChild(document.createTextNode('Your name'));
714 const nameInput = document.createElement('input');
715 nameInput.type = 'text';
716 nameInput.maxLength = 20;
717 nameInput.required = true;
718 nameInput.autocomplete = 'off';
719 nameInput.value = getSavedName();
720 label.appendChild(nameInput);
721 form.appendChild(label);
722 const btn = document.createElement('button');
723 btn.type = 'submit';
724 btn.textContent = 'Join game';
725 form.appendChild(btn);
726 wrap.appendChild(form);
727 app.appendChild(wrap);
728 form.addEventListener('submit', (e) => {
729 e.preventDefault();
730 const name = nameInput.value.trim();
731 if (name) { saveName(name); onName(name); }
732 });
733}
734
735// ---------------------------------------------------------------------------
736// Error display
737// ---------------------------------------------------------------------------
738
739function showError(app: HTMLElement, message: string): void {
740 app.innerHTML = '';
741 const p = document.createElement('p');
742 p.className = 'error';
743 p.textContent = message;
744 app.appendChild(p);
745}
746
747// ---------------------------------------------------------------------------
748// Bootstrap
749// ---------------------------------------------------------------------------
750
751async function bootstrap(): Promise<void> {
752 const app = document.getElementById('app');
753 if (!app) throw new Error('#app element not found');
754
755 const { gameId, key, role: urlRole } = parseLink();
756
757 if (!gameId || !key) {
758 showHomeScreen(app);
759 return;
760 }
761
762 let cryptoKey: CryptoKey;
763 try {
764 cryptoKey = await importKey(key);
765 } catch {
766 showError(app, 'Invalid game link — the encryption key is malformed.');
767 return;
768 }
769
770 const envelope = await getGame(gameId);
771 if (!envelope) {
772 showError(app, 'Game not found — the link may be expired or invalid.');
773 return;
774 }
775
776 let state: GameState;
777 try {
778 state = JSON.parse(await decrypt(envelope, cryptoKey)) as GameState;
779 } catch {
780 showError(app, 'Could not decrypt game state — the link may be corrupted.');
781 return;
782 }
783
784 // Determine player role.
785 // Priority: localStorage (same device / browser) → role encoded in the URL
786 // → heuristic (B slot empty means this is B's first visit).
787 let myRole = getRole(gameId);
788 if (!myRole) {
789 if (urlRole === 'b' || (!urlRole && state.players.b.name === '')) {
790 // Player B's first visit — prompt for name then join
791 await new Promise<void>((resolve) => {
792 promptPlayerBName(app, async (name) => {
793 const updated: GameState = {
794 ...state,
795 players: { ...state.players, b: { name, score: 0 } },
796 updatedAt: Date.now(),
797 };
798 const updatedEnvelope = await encrypt(JSON.stringify(updated), cryptoKey);
799 const joinNotify: PendingNotification = {
800 player: 'a',
801 message: `${name} has joined and is solving your word!`,
802 gameUrl: buildLink(gameId, key).split('#')[0],
803 };
804 await updateGame(gameId, updatedEnvelope, joinNotify);
805 state = updated;
806 setRole(gameId, 'b');
807 myRole = 'b';
808 resolve();
809 });
810 });
811 } else {
812 // URL says role=a, or no URL role and B already joined → this is player A
813 // (e.g. creator rejoining on a new device).
814 setRole(gameId, 'a');
815 myRole = 'a';
816 }
817 }
818
819 const role = myRole as PlayerId;
820 const shareUrl = buildLink(gameId, key);
821
822 // Register the service worker and subscribe to push notifications.
823 // Fire-and-forget: push is a best-effort enhancement.
824 void (async () => {
825 await registerServiceWorker();
826 const subJson = await subscribeToPush();
827 if (subJson) {
828 storePushSubscription(gameId, role, subJson).catch((err: unknown) =>
829 console.warn('Failed to store push subscription:', err),
830 );
831 }
832 })();
833
834 render(app, state, role, gameId, cryptoKey, key, shareUrl);
835
836 // Track whether it was already my turn when we first loaded so we only
837 // notify on *transitions* (opponent finished), not on initial page open.
838 let wasMyTurn = isMyTurn(state, role);
839
840 unsubscribeGame?.();
841 unsubscribeGame = subscribeToGame(gameId, async (newEnvelope) => {
842 try {
843 const newState = JSON.parse(await decrypt(newEnvelope, cryptoKey)) as GameState;
844 const myTurnNow = isMyTurn(newState, role);
845 if (myTurnNow && !wasMyTurn) {
846 const msg = newState.turn === 'pick' ? "It's your turn to pick a word!" : "It's your turn to solve the word!";
847 notifyMyTurn(msg);
848 }
849 wasMyTurn = myTurnNow;
850 render(app, newState, role, gameId, cryptoKey, key, shareUrl);
851 } catch (err) {
852 console.error('Failed to decrypt game update:', err);
853 showError(app, 'Could not decrypt the latest game update — the link may be corrupted.');
854 }
855 });
856}
857
858// Home link — clear the fragment and re-show the new-game screen without a
859// full page reload. This works regardless of the deployment base path.
860document.getElementById('home-link')?.addEventListener('click', (e) => {
861 e.preventDefault();
862 unsubscribeGame?.();
863 unsubscribeGame = null;
864 cleanupView?.();
865 cleanupView = null;
866 clearNotification();
867 history.replaceState(null, '', window.location.pathname);
868 updateShareButton();
869 const app = document.getElementById('app');
870 if (app) showHomeScreen(app);
871});
872
873// Clear the alert title as soon as the user focuses the tab
874document.addEventListener('visibilitychange', () => {
875 if (document.visibilityState === 'visible') clearNotification();
876});
877
878// Re-run bootstrap whenever the fragment changes.
879window.addEventListener('hashchange', () => {
880 unsubscribeGame?.();
881 unsubscribeGame = null;
882 cleanupView?.();
883 cleanupView = null;
884 updateShareButton();
885 bootstrap().catch((err: unknown) => {
886 console.error('Bootstrap failed:', err);
887 const app = document.getElementById('app');
888 if (app) showError(app, 'Something went wrong loading the game. Please refresh.');
889 });
890});
891
892updateShareButton();
893bootstrap().catch((err: unknown) => {
894 console.error('Bootstrap failed:', err);
895 const app = document.getElementById('app');
896 if (app) showError(app, 'Something went wrong loading the game. Please refresh.');
897});
898
899// ---------------------------------------------------------------------------
900// Notification status button — always visible in the header
901// ---------------------------------------------------------------------------
902
903function initNotifButton(): void {
904 const btn = document.getElementById('notif-btn') as HTMLButtonElement | null;
905 if (!btn) return;
906
907 if (!('Notification' in window)) {
908 btn.hidden = true;
909 return;
910 }
911
912 // Replace button content with the icon image
913 btn.textContent = '';
914 const img = document.createElement('img');
915 img.alt = 'Notifications';
916 btn.appendChild(img);
917
918 function updateBtn(): void {
919 const perm = Notification.permission;
920 const optedOut = isNotifOptedOut();
921 if (perm === 'granted' && !optedOut) {
922 img.src = speakerIconUrl;
923 btn!.title = 'Notifications enabled — click to disable';
924 btn!.classList.add('header__notif-btn--on');
925 btn!.classList.remove('header__notif-btn--blocked');
926 } else if (perm === 'denied') {
927 img.src = muteIconUrl;
928 btn!.title = 'Notifications blocked by browser';
929 btn!.classList.remove('header__notif-btn--on');
930 btn!.classList.add('header__notif-btn--blocked');
931 } else if (perm === 'granted' && optedOut) {
932 img.src = muteIconUrl;
933 btn!.title = 'Notifications disabled — click to enable';
934 btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked');
935 } else {
936 img.src = muteIconUrl;
937 btn!.title = 'Enable notifications';
938 btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked');
939 }
940 }
941
942 updateBtn();
943
944 btn.addEventListener('click', async () => {
945 const perm = Notification.permission;
946
947 if (perm === 'denied') {
948 alert("Notifications are blocked. To enable them, click the lock/info icon in your browser's address bar, find Notifications, and set it to Allow.");
949 return;
950 }
951
952 if (perm === 'granted') {
953 if (isNotifOptedOut()) {
954 // Re-enable
955 setNotifOptOut(false);
956 updateBtn();
957 await subscribeToPush();
958 } else {
959 // Disable
960 setNotifOptOut(true);
961 updateBtn();
962 await unsubscribeFromPush();
963 }
964 return;
965 }
966
967 // 'default' — request permission
968 await registerServiceWorker();
969 const result = await Notification.requestPermission();
970 setNotifOptOut(false);
971 updateBtn();
972 if (result === 'granted') {
973 const reg = await registerServiceWorker();
974 if (reg) {
975 await reg.showNotification('Word Waltz', {
976 body: "Notifications are working! You'll be alerted when it's your turn.",
977 icon: `${import.meta.env.BASE_URL}apple-touch-icon.png`,
978 tag: 'word-waltz-test',
979 });
980 }
981 }
982 });
983}
984
985initNotifButton();
986