@mideind/netskrafl-react 1.6.1 → 1.7.1

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/dist/esm/index.js CHANGED
@@ -1,8 +1,65 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import React, { useEffect } from 'react';
3
3
 
4
+ const DEFAULT_STATE = {
5
+ projectId: "netskrafl",
6
+ firebaseApiKey: "",
7
+ databaseUrl: "",
8
+ firebaseSenderId: "",
9
+ firebaseAppId: "",
10
+ measurementId: "",
11
+ account: "",
12
+ userEmail: "",
13
+ userId: "",
14
+ userNick: "",
15
+ userFullname: "",
16
+ locale: "is_IS",
17
+ isExplo: false,
18
+ serverUrl: "",
19
+ movesUrl: "",
20
+ movesAccessKey: "",
21
+ token: "",
22
+ loginMethod: "",
23
+ subscriptionUrl: "",
24
+ newUser: false,
25
+ beginner: true,
26
+ fairPlay: false,
27
+ plan: "", // Not a friend
28
+ hasPaid: false,
29
+ audio: false,
30
+ fanfare: false,
31
+ ready: true,
32
+ readyTimed: true,
33
+ uiFullscreen: true,
34
+ uiLandscape: false,
35
+ runningLocal: false,
36
+ };
37
+
4
38
  // Key for storing auth settings in sessionStorage
5
39
  const AUTH_SETTINGS_KEY = "netskrafl_auth_settings";
40
+ const makeServerUrls = (backendUrl, movesUrl) => {
41
+ // If the last character of the url is a slash, cut it off,
42
+ // since path URLs always start with a slash
43
+ const cleanupUrl = (url) => {
44
+ if (url.length > 0 && url[url.length - 1] === "/") {
45
+ url = url.slice(0, -1);
46
+ }
47
+ return url;
48
+ };
49
+ return {
50
+ serverUrl: cleanupUrl(backendUrl),
51
+ movesUrl: cleanupUrl(movesUrl),
52
+ };
53
+ };
54
+ const makeGlobalState = (overrides) => {
55
+ const state = {
56
+ ...DEFAULT_STATE,
57
+ ...overrides,
58
+ };
59
+ const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
60
+ // Apply any persisted authentication settings from sessionStorage
61
+ return applyPersistedSettings(stateWithUrls);
62
+ };
6
63
  // Save authentication settings to sessionStorage
7
64
  const saveAuthSettings = (settings) => {
8
65
  if (!settings) {
@@ -31,6 +88,10 @@ const saveAuthSettings = (settings) => {
31
88
  filteredSettings.ready = settings.ready;
32
89
  if (settings.readyTimed !== undefined)
33
90
  filteredSettings.readyTimed = settings.readyTimed;
91
+ if (settings.audio !== undefined)
92
+ filteredSettings.audio = settings.audio;
93
+ if (settings.fanfare !== undefined)
94
+ filteredSettings.fanfare = settings.fanfare;
34
95
  // Only save if we have actual settings to persist
35
96
  if (Object.keys(filteredSettings).length > 1) {
36
97
  sessionStorage.setItem(AUTH_SETTINGS_KEY, JSON.stringify(filteredSettings));
@@ -69,7 +130,7 @@ const clearAuthSettings = () => {
69
130
  };
70
131
  // Apply persisted settings to a GlobalState object
71
132
  const applyPersistedSettings = (state) => {
72
- var _a, _b, _c, _d;
133
+ var _a, _b, _c, _d, _e, _f;
73
134
  const persisted = loadAuthSettings();
74
135
  if (!persisted) {
75
136
  return state;
@@ -95,64 +156,11 @@ const applyPersistedSettings = (state) => {
95
156
  fairPlay: (_b = persisted.fairPlay) !== null && _b !== void 0 ? _b : state.fairPlay,
96
157
  ready: (_c = persisted.ready) !== null && _c !== void 0 ? _c : state.ready,
97
158
  readyTimed: (_d = persisted.readyTimed) !== null && _d !== void 0 ? _d : state.readyTimed,
159
+ audio: (_e = persisted.audio) !== null && _e !== void 0 ? _e : state.audio,
160
+ fanfare: (_f = persisted.fanfare) !== null && _f !== void 0 ? _f : state.fanfare,
98
161
  };
99
162
  };
100
163
 
101
- const DEFAULT_STATE = {
102
- projectId: "netskrafl",
103
- firebaseApiKey: "",
104
- databaseUrl: "",
105
- firebaseSenderId: "",
106
- firebaseAppId: "",
107
- measurementId: "",
108
- account: "",
109
- userEmail: "",
110
- userId: "",
111
- userNick: "",
112
- userFullname: "",
113
- locale: "is_IS",
114
- isExplo: false,
115
- serverUrl: "",
116
- movesUrl: "",
117
- movesAccessKey: "",
118
- token: "",
119
- loginMethod: "",
120
- subscriptionUrl: "",
121
- newUser: false,
122
- beginner: true,
123
- fairPlay: false,
124
- plan: "", // Not a friend
125
- hasPaid: false,
126
- ready: true,
127
- readyTimed: true,
128
- uiFullscreen: true,
129
- uiLandscape: false,
130
- runningLocal: false,
131
- };
132
- const makeServerUrls = (backendUrl, movesUrl) => {
133
- // If the last character of the url is a slash, cut it off,
134
- // since path URLs always start with a slash
135
- const cleanupUrl = (url) => {
136
- if (url.length > 0 && url[url.length - 1] === "/") {
137
- url = url.slice(0, -1);
138
- }
139
- return url;
140
- };
141
- return {
142
- serverUrl: cleanupUrl(backendUrl),
143
- movesUrl: cleanupUrl(movesUrl),
144
- };
145
- };
146
- const makeGlobalState = (overrides) => {
147
- const state = {
148
- ...DEFAULT_STATE,
149
- ...overrides,
150
- };
151
- const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
152
- // Apply any persisted authentication settings from sessionStorage
153
- return applyPersistedSettings(stateWithUrls);
154
- };
155
-
156
164
  function getDefaultExportFromCjs (x) {
157
165
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
158
166
  }
@@ -2533,6 +2541,133 @@ const ERROR_MESSAGES = {
2533
2541
  "server": "Netþjónn gat ekki tekið við leiknum - reyndu aftur"
2534
2542
  };
2535
2543
 
2544
+ /*
2545
+
2546
+ Audio.ts
2547
+
2548
+ Audio management service for Netskrafl/Explo
2549
+
2550
+ Copyright (C) 2025 Miðeind ehf.
2551
+ Author: Vilhjálmur Þorsteinsson
2552
+
2553
+ The Creative Commons Attribution-NonCommercial 4.0
2554
+ International Public License (CC-BY-NC 4.0) applies to this software.
2555
+ For further information, see https://github.com/mideind/Netskrafl
2556
+
2557
+ */
2558
+ /**
2559
+ * AudioManager handles preloading and playback of sound effects.
2560
+ * It creates HTMLAudioElement instances for each sound and manages their lifecycle.
2561
+ */
2562
+ class AudioManager {
2563
+ constructor(state, soundUrls) {
2564
+ this.sounds = new Map();
2565
+ this.initialized = false;
2566
+ // By default, sound URLs are based on /static on the backend server
2567
+ const DEFAULT_SOUND_BASE = serverUrl(state, "/static");
2568
+ const DEFAULT_SOUND_URLS = {
2569
+ "your-turn": `${DEFAULT_SOUND_BASE}/your-turn.mp3`,
2570
+ "you-win": `${DEFAULT_SOUND_BASE}/you-win.mp3`,
2571
+ "new-msg": `${DEFAULT_SOUND_BASE}/new-msg.mp3`,
2572
+ };
2573
+ // Merge provided URLs with defaults
2574
+ this.soundUrls = {
2575
+ ...DEFAULT_SOUND_URLS,
2576
+ ...(soundUrls || {}),
2577
+ };
2578
+ }
2579
+ /**
2580
+ * Initialize the audio manager by creating and preloading audio elements.
2581
+ * This should be called once when the application starts.
2582
+ */
2583
+ initialize() {
2584
+ if (this.initialized) {
2585
+ return;
2586
+ }
2587
+ // Create audio elements for each sound
2588
+ Object.entries(this.soundUrls).forEach(([soundId, url]) => {
2589
+ const audio = new Audio(url);
2590
+ audio.preload = "auto";
2591
+ // Handle load errors gracefully - don't let them crash the app
2592
+ audio.addEventListener("error", () => {
2593
+ console.warn(`Failed to load audio: ${soundId} from ${url}`);
2594
+ });
2595
+ this.sounds.set(soundId, audio);
2596
+ });
2597
+ this.initialized = true;
2598
+ }
2599
+ /**
2600
+ * Play a sound by its ID.
2601
+ * If the sound is not loaded or fails to play, the error is logged but doesn't throw.
2602
+ *
2603
+ * @param soundId The identifier of the sound to play
2604
+ */
2605
+ play(soundId) {
2606
+ if (!this.initialized) {
2607
+ this.initialize();
2608
+ }
2609
+ const audio = this.sounds.get(soundId);
2610
+ if (!audio) {
2611
+ console.warn(`Audio not found: ${soundId}`);
2612
+ return;
2613
+ }
2614
+ // Reset to start in case it's already playing
2615
+ audio.currentTime = 0;
2616
+ // Play the audio - catch any errors (e.g., user hasn't interacted with page yet)
2617
+ audio.play().catch((err) => {
2618
+ // This is expected in some cases (e.g., autoplay restrictions)
2619
+ // so we just log it at debug level
2620
+ if (err.name !== "NotAllowedError") {
2621
+ console.warn(`Failed to play audio ${soundId}:`, err);
2622
+ }
2623
+ });
2624
+ }
2625
+ /**
2626
+ * Update the URL for a specific sound.
2627
+ * This will recreate the audio element with the new URL.
2628
+ *
2629
+ * @param soundId The identifier of the sound to update
2630
+ * @param url The new URL for the sound
2631
+ */
2632
+ updateSoundUrl(soundId, url) {
2633
+ this.soundUrls[soundId] = url;
2634
+ // If already initialized, recreate this audio element
2635
+ if (this.initialized) {
2636
+ const audio = new Audio(url);
2637
+ audio.preload = "auto";
2638
+ audio.addEventListener("error", () => {
2639
+ console.warn(`Failed to load audio: ${soundId} from ${url}`);
2640
+ });
2641
+ this.sounds.set(soundId, audio);
2642
+ }
2643
+ }
2644
+ /**
2645
+ * Dispose of all audio elements and clean up resources.
2646
+ */
2647
+ dispose() {
2648
+ this.sounds.forEach((audio) => {
2649
+ audio.pause();
2650
+ audio.src = "";
2651
+ });
2652
+ this.sounds.clear();
2653
+ this.initialized = false;
2654
+ }
2655
+ }
2656
+ // Global singleton instance
2657
+ let audioManager = null;
2658
+ /**
2659
+ * Get or create the global AudioManager instance.
2660
+ *
2661
+ * @param soundUrls Optional custom sound URLs (only used on first call)
2662
+ * @returns The global AudioManager instance
2663
+ */
2664
+ function getAudioManager(state, soundUrls) {
2665
+ if (!audioManager) {
2666
+ audioManager = new AudioManager(state, soundUrls);
2667
+ }
2668
+ return audioManager;
2669
+ }
2670
+
2536
2671
  /*
2537
2672
 
2538
2673
  Util.ts
@@ -2689,11 +2824,10 @@ function setInput(id, val) {
2689
2824
  const elem = document.getElementById(id);
2690
2825
  elem.value = val;
2691
2826
  }
2692
- function playAudio(elemId) {
2693
- // Play an audio file
2694
- const sound = document.getElementById(elemId);
2695
- if (sound)
2696
- sound.play();
2827
+ function playAudio(state, soundId) {
2828
+ // Play an audio file using the AudioManager
2829
+ const audioManager = getAudioManager(state);
2830
+ audioManager.play(soundId);
2697
2831
  }
2698
2832
  function arrayEqual(a, b) {
2699
2833
  // Return true if arrays a and b are equal
@@ -27230,6 +27364,19 @@ let app;
27230
27364
  let auth;
27231
27365
  let database;
27232
27366
  let analytics;
27367
+ // Queue of callbacks to execute when database is ready
27368
+ let databaseReadyQueue = [];
27369
+ /**
27370
+ * Execute all queued callbacks now that database is ready
27371
+ */
27372
+ function flushDatabaseReadyQueue(state) {
27373
+ var _a;
27374
+ const queue = [...databaseReadyQueue];
27375
+ databaseReadyQueue = []; // Clear queue before executing to avoid re-queuing
27376
+ queue.forEach(callback => callback());
27377
+ // Also notify the global callback if set
27378
+ (_a = state.onFirebaseReady) === null || _a === void 0 ? void 0 : _a.call(state);
27379
+ }
27233
27380
  function initFirebase(state) {
27234
27381
  try {
27235
27382
  const { projectId, firebaseApiKey, databaseUrl, firebaseSenderId, firebaseAppId, measurementId } = state;
@@ -27264,9 +27411,30 @@ function isFirebaseAuthenticated(state) {
27264
27411
  if (app)
27265
27412
  auth = getAuth(app);
27266
27413
  }
27267
- if (!auth)
27414
+ if (!auth || auth.currentUser === null) {
27268
27415
  return false;
27269
- return auth.currentUser !== null;
27416
+ }
27417
+ // If auth is valid but database not initialized, initialize it
27418
+ // This handles the case where the user has cached auth but returns
27419
+ // directly to a route that needs Firebase Database (e.g., Gáta Dagsins)
27420
+ if (!database && app) {
27421
+ database = getDatabase(app);
27422
+ if (!database) {
27423
+ console.error("Failed to initialize Firebase Database");
27424
+ return false;
27425
+ }
27426
+ // Database initialized successfully - flush queued callbacks
27427
+ flushDatabaseReadyQueue(state);
27428
+ // Also initialize analytics if not already done
27429
+ if (!analytics) {
27430
+ analytics = getAnalytics(app);
27431
+ if (!analytics) {
27432
+ console.error("Failed to initialize Firebase Analytics");
27433
+ // We don't return false here since analytics is not critical
27434
+ }
27435
+ }
27436
+ }
27437
+ return true;
27270
27438
  }
27271
27439
  async function loginFirebase(state, firebaseToken, onLoginFunc) {
27272
27440
  if (!app && !initFirebase(state))
@@ -27304,6 +27472,10 @@ async function loginFirebase(state, firebaseToken, onLoginFunc) {
27304
27472
  if (!database) {
27305
27473
  console.error("Failed to initialize Firebase Database");
27306
27474
  }
27475
+ else {
27476
+ // Database initialized successfully - flush queued callbacks
27477
+ flushDatabaseReadyQueue(state);
27478
+ }
27307
27479
  analytics = getAnalytics(app);
27308
27480
  if (!analytics) {
27309
27481
  console.error("Failed to initialize Firebase Analytics");
@@ -27335,8 +27507,12 @@ function initPresence(projectId, userId, locale) {
27335
27507
  }
27336
27508
  function attachFirebaseListener(path, func) {
27337
27509
  // Attach a message listener to a Firebase path
27338
- if (!database)
27510
+ // console.log(`attachFirebaseListener(${path})`);
27511
+ if (!database) {
27512
+ // Database not ready yet - queue this attachment for later
27513
+ databaseReadyQueue.push(() => attachFirebaseListener(path, func));
27339
27514
  return;
27515
+ }
27340
27516
  let cnt = 0;
27341
27517
  const pathRef = ref(database, path);
27342
27518
  onValue(pathRef, function (snapshot) {
@@ -27353,8 +27529,10 @@ function attachFirebaseListener(path, func) {
27353
27529
  }
27354
27530
  function detachFirebaseListener(path) {
27355
27531
  // Detach a message listener from a Firebase path
27356
- if (!database)
27532
+ // console.log(`detachFirebaseListener(${path})`);
27533
+ if (!database) {
27357
27534
  return;
27535
+ }
27358
27536
  const pathRef = ref(database, path);
27359
27537
  off(pathRef);
27360
27538
  }
@@ -27373,63 +27551,178 @@ async function getFirebaseData(path) {
27373
27551
  return snapshot.val();
27374
27552
  }
27375
27553
 
27376
- // Global state for authentication
27554
+ // ============================================================================
27555
+ // Configuration constants for login protection
27556
+ // ============================================================================
27557
+ // Maximum number of login attempts before giving up
27558
+ const MAX_LOGIN_RETRIES = 3;
27559
+ // Minimum time between login attempts (milliseconds) - prevents rapid-fire retries
27560
+ const MIN_LOGIN_INTERVAL_MS = 500;
27561
+ // Initial backoff delay for retries (milliseconds) - doubles each retry
27562
+ const INITIAL_BACKOFF_MS = 500;
27563
+ // Maximum backoff delay (milliseconds) - caps exponential growth
27564
+ const MAX_BACKOFF_MS = 5000;
27565
+ // Circuit breaker timeout (milliseconds) - how long to wait after max retries
27566
+ const CIRCUIT_BREAKER_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
27567
+ // ============================================================================
27568
+ // Global state for authentication and retry protection
27569
+ // ============================================================================
27570
+ // Active login promise - prevents concurrent login attempts
27377
27571
  let authPromise = null;
27378
- // Custom error class for authentication failures
27572
+ // Retry tracking
27573
+ let loginAttemptCount = 0;
27574
+ let lastLoginAttemptTime = 0;
27575
+ // Circuit breaker state
27576
+ let circuitBreakerOpen = false;
27577
+ let circuitBreakerResetTime = 0;
27578
+ // ============================================================================
27579
+ // Custom error classes
27580
+ // ============================================================================
27379
27581
  class AuthenticationError extends Error {
27380
27582
  constructor() {
27381
27583
  super("Authentication required");
27382
27584
  this.name = "AuthenticationError";
27383
27585
  }
27384
27586
  }
27385
- // Internal function to ensure authentication
27587
+ class LoginThrottledError extends Error {
27588
+ constructor(message) {
27589
+ super(message);
27590
+ this.name = "LoginThrottledError";
27591
+ }
27592
+ }
27593
+ // ============================================================================
27594
+ // Helper functions
27595
+ // ============================================================================
27596
+ /**
27597
+ * Delays execution for the specified number of milliseconds
27598
+ */
27599
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
27600
+ /**
27601
+ * Calculates exponential backoff delay based on attempt number
27602
+ */
27603
+ const calculateBackoff = (attemptNumber) => {
27604
+ const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attemptNumber - 1);
27605
+ return Math.min(backoff, MAX_BACKOFF_MS);
27606
+ };
27607
+ /**
27608
+ * Resets all retry and circuit breaker state to initial values
27609
+ */
27610
+ const resetLoginState = () => {
27611
+ loginAttemptCount = 0;
27612
+ circuitBreakerOpen = false;
27613
+ circuitBreakerResetTime = 0;
27614
+ };
27615
+ /**
27616
+ * Opens the circuit breaker to prevent further login attempts
27617
+ */
27618
+ const openCircuitBreaker = () => {
27619
+ circuitBreakerOpen = true;
27620
+ circuitBreakerResetTime = Date.now() + CIRCUIT_BREAKER_TIMEOUT_MS;
27621
+ loginAttemptCount = 0;
27622
+ console.error(`Circuit breaker opened. Login attempts blocked until ${new Date(circuitBreakerResetTime).toLocaleTimeString()}`);
27623
+ };
27624
+ /**
27625
+ * Checks if circuit breaker should be reset based on timeout
27626
+ */
27627
+ const checkCircuitBreakerReset = () => {
27628
+ if (circuitBreakerOpen && Date.now() >= circuitBreakerResetTime) {
27629
+ resetLoginState();
27630
+ }
27631
+ };
27632
+ // ============================================================================
27633
+ // Authentication flow
27634
+ // ============================================================================
27635
+ /**
27636
+ * Internal function to ensure authentication with comprehensive retry protection
27637
+ *
27638
+ * Protection mechanisms:
27639
+ * - Circuit breaker: Stops all attempts after max retries for a cooldown period
27640
+ * - Retry limits: Maximum number of consecutive failed attempts
27641
+ * - Exponential backoff: Increasing delays between retries
27642
+ * - Rate limiting: Minimum time between attempts
27643
+ * - Request deduplication: Only one login attempt at a time
27644
+ */
27386
27645
  const ensureAuthenticated = async (state) => {
27387
- var _a, _b, _c, _d, _e, _f, _g, _h;
27646
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
27647
+ // Check and potentially reset circuit breaker
27648
+ checkCircuitBreakerReset();
27649
+ // Circuit breaker check - fail fast if too many recent failures
27650
+ if (circuitBreakerOpen) {
27651
+ const waitMinutes = Math.ceil((circuitBreakerResetTime - Date.now()) / 60000);
27652
+ throw new LoginThrottledError(`Too many failed login attempts. Please wait ${waitMinutes} minute(s) before trying again.`);
27653
+ }
27388
27654
  // If login is already in progress, wait for it to complete
27389
27655
  if (authPromise) {
27390
27656
  await authPromise;
27391
27657
  return;
27392
27658
  }
27659
+ // Rate limiting - prevent rapid-fire retries
27660
+ const timeSinceLastAttempt = Date.now() - lastLoginAttemptTime;
27661
+ if (timeSinceLastAttempt < MIN_LOGIN_INTERVAL_MS && loginAttemptCount > 0) {
27662
+ console.warn(`Login attempt too soon (${timeSinceLastAttempt}ms since last attempt)`);
27663
+ throw new LoginThrottledError("Please wait a moment before trying again.");
27664
+ }
27665
+ // Retry loop with exponential backoff
27393
27666
  let continueTrying = true;
27394
- while (continueTrying) {
27667
+ while (continueTrying && loginAttemptCount < MAX_LOGIN_RETRIES) {
27395
27668
  continueTrying = false;
27396
- // Start new login attempt (either forced by 401 or needed for Firebase)
27669
+ loginAttemptCount++;
27670
+ lastLoginAttemptTime = Date.now();
27671
+ // Apply exponential backoff for retries (skip delay on first attempt)
27672
+ if (loginAttemptCount >= 2) {
27673
+ const backoffMs = calculateBackoff(loginAttemptCount - 1);
27674
+ console.warn(`Login retry ${loginAttemptCount}/${MAX_LOGIN_RETRIES}, waiting ${backoffMs}ms...`);
27675
+ await delay(backoffMs);
27676
+ }
27677
+ // Start new login attempt
27397
27678
  authPromise = loginUserByEmail(state);
27398
27679
  try {
27399
27680
  const result = await authPromise;
27681
+ // Handle expired token
27400
27682
  if (result.status === "expired") {
27401
- // Token has expired, notify the React component if callback is set
27402
27683
  if (state.tokenExpired) {
27403
- // We have a callback to renew the token: do it and try again
27404
- state.tokenExpired();
27405
- continueTrying = true; // Try logging in again
27406
- clearAuthSettings();
27407
- continue;
27684
+ // Token refresh callback is available
27685
+ try {
27686
+ // Call the token refresh callback
27687
+ state.tokenExpired();
27688
+ continueTrying = true;
27689
+ clearAuthSettings();
27690
+ continue;
27691
+ }
27692
+ catch (error) {
27693
+ console.error("Token refresh failed:", error);
27694
+ clearAuthSettings();
27695
+ throw new Error(`Failed to refresh authentication token: ${error instanceof Error ? error.message : String(error)}`);
27696
+ }
27408
27697
  }
27409
- // Clear any persisted settings since they're no longer valid
27698
+ // No refresh callback available
27410
27699
  clearAuthSettings();
27411
- throw new Error("Authentication token has expired");
27700
+ throw new Error("Authentication token has expired. Please log in again.");
27412
27701
  }
27413
- else if (result.status !== "success") {
27414
- // Clear any persisted settings on auth failure
27702
+ // Handle other non-success statuses
27703
+ if (result.status !== "success") {
27415
27704
  clearAuthSettings();
27416
27705
  throw new Error(`Authentication failed: ${result.message || result.status}`);
27417
27706
  }
27707
+ // ========================================================================
27708
+ // Success! Update global state and persist authentication
27709
+ // ========================================================================
27418
27710
  // Update the user's ID to the internal one used by the backend and Firebase
27419
27711
  state.userId = result.user_id || state.userId;
27420
27712
  state.account = result.account || state.userId;
27421
- // Update the user's nickname
27422
27713
  state.userNick = result.nickname || state.userNick;
27423
- // Use the server's Firebase API key, if provided
27424
27714
  state.firebaseApiKey = result.firebase_api_key || state.firebaseApiKey;
27425
27715
  // Load state flags and preferences
27426
27716
  state.beginner = (_b = (_a = result.prefs) === null || _a === void 0 ? void 0 : _a.beginner) !== null && _b !== void 0 ? _b : true;
27427
27717
  state.fairPlay = (_d = (_c = result.prefs) === null || _c === void 0 ? void 0 : _c.fairplay) !== null && _d !== void 0 ? _d : false;
27428
27718
  state.ready = (_f = (_e = result.prefs) === null || _e === void 0 ? void 0 : _e.ready) !== null && _f !== void 0 ? _f : true;
27429
27719
  state.readyTimed = (_h = (_g = result.prefs) === null || _g === void 0 ? void 0 : _g.ready_timed) !== null && _h !== void 0 ? _h : true;
27430
- // Save the authentication settings to sessionStorage for persistence
27720
+ state.audio = (_k = (_j = result.prefs) === null || _j === void 0 ? void 0 : _j.audio) !== null && _k !== void 0 ? _k : false;
27721
+ state.fanfare = (_m = (_l = result.prefs) === null || _l === void 0 ? void 0 : _l.fanfare) !== null && _m !== void 0 ? _m : false;
27722
+ state.hasPaid = (_p = (_o = result.prefs) === null || _o === void 0 ? void 0 : _o.haspaid) !== null && _p !== void 0 ? _p : false;
27723
+ // Save authentication settings to sessionStorage
27431
27724
  saveAuthSettings({
27432
- userEmail: state.userEmail, // CRITICAL: Include email to validate ownership
27725
+ userEmail: state.userEmail,
27433
27726
  userId: state.userId,
27434
27727
  userNick: state.userNick,
27435
27728
  firebaseApiKey: state.firebaseApiKey,
@@ -27437,18 +27730,39 @@ const ensureAuthenticated = async (state) => {
27437
27730
  fairPlay: state.fairPlay,
27438
27731
  ready: state.ready,
27439
27732
  readyTimed: state.readyTimed,
27733
+ audio: state.audio,
27734
+ fanfare: state.fanfare,
27440
27735
  });
27441
- // Be sure to redraw Mithril components after authentication;
27442
- // user info may have changed
27736
+ // Redraw UI to reflect authentication changes
27443
27737
  m.redraw();
27444
- // Success: Log in to Firebase with the token passed from the server
27738
+ // Log in to Firebase with the token from server
27445
27739
  await loginFirebase(state, result.firebase_token);
27740
+ // Success - reset all retry state
27741
+ resetLoginState();
27742
+ }
27743
+ catch (error) {
27744
+ // On last retry, open circuit breaker
27745
+ if (loginAttemptCount >= MAX_LOGIN_RETRIES) {
27746
+ openCircuitBreaker();
27747
+ throw new Error(`Login failed after ${MAX_LOGIN_RETRIES} attempts. ` +
27748
+ `Please wait ${Math.ceil(CIRCUIT_BREAKER_TIMEOUT_MS / 60000)} minutes before trying again.`);
27749
+ }
27750
+ // Re-throw error if we're not retrying
27751
+ if (!continueTrying) {
27752
+ throw error;
27753
+ }
27754
+ // Continue to next retry iteration
27446
27755
  }
27447
27756
  finally {
27448
- // Reset the promise so future 401s can trigger a new login
27757
+ // Reset the promise so future attempts can proceed
27449
27758
  authPromise = null;
27450
27759
  }
27451
27760
  }
27761
+ // Should not reach here, but handle max retries edge case
27762
+ if (loginAttemptCount >= MAX_LOGIN_RETRIES) {
27763
+ openCircuitBreaker();
27764
+ throw new Error("Maximum login attempts exceeded. Please try again later.");
27765
+ }
27452
27766
  };
27453
27767
  // Internal authenticated request function
27454
27768
  const authenticatedRequest = async (state, options, retries = 0) => {
@@ -27696,43 +28010,49 @@ const UserInfoButton = {
27696
28010
  }, isRobot ? "" : m("span.usr-info"));
27697
28011
  }
27698
28012
  };
27699
- const OnlinePresence = (initialVnode) => {
28013
+ const OnlinePresence = {
27700
28014
  // Shows an icon in grey or green depending on whether a given user
27701
28015
  // is online or not. If attrs.online is given (i.e. not undefined),
27702
28016
  // that value is used and displayed; otherwise the server is asked.
27703
- const attrs = initialVnode.attrs;
27704
- let online = attrs.online ? true : false;
27705
- const askServer = attrs.online === undefined;
27706
- const id = attrs.id;
27707
- const userId = attrs.userId;
27708
- const state = attrs.state;
27709
- let loading = false;
27710
- async function _update() {
27711
- if (askServer && !loading) {
27712
- loading = true;
27713
- const json = await request(state, {
27714
- method: "POST",
27715
- url: "/onlinecheck",
27716
- body: { user: userId }
27717
- });
27718
- online = json && json.online;
27719
- loading = false;
28017
+ oninit: (vnode) => {
28018
+ const { state } = vnode;
28019
+ state.online = false;
28020
+ },
28021
+ oncreate: (vnode) => {
28022
+ // Note: we must use a two-step initialization here,
28023
+ // calling oninit() first and then oncreate(), since calls to
28024
+ // m.redraw() - explicit or implicit via m.request() - from within
28025
+ // oninit() will cause a recursive call to oninit(), resulting in
28026
+ // a double API call to the backend.
28027
+ const { attrs, state } = vnode;
28028
+ state.online = !!attrs.online;
28029
+ if (attrs.online === undefined) {
28030
+ // We need to ask the server for the online status
28031
+ const askServer = async () => {
28032
+ try {
28033
+ const json = await request(attrs.state, {
28034
+ method: "POST",
28035
+ url: "/onlinecheck",
28036
+ body: { user: attrs.userId }
28037
+ });
28038
+ state.online = json && json.online;
28039
+ }
28040
+ catch (e) {
28041
+ state.online = false;
28042
+ }
28043
+ };
28044
+ askServer(); // Fire-and-forget
27720
28045
  }
28046
+ },
28047
+ view: (vnode) => {
28048
+ const { attrs, state } = vnode;
28049
+ const online = state.online || (!!attrs.online);
28050
+ return m("span", {
28051
+ id: attrs.id,
28052
+ title: online ? ts("Er álínis") : ts("Álínis?"),
28053
+ class: online ? "online" : ""
28054
+ });
27721
28055
  }
27722
- return {
27723
- oninit: _update,
27724
- view: (vnode) => {
27725
- var _a, _b;
27726
- if (!askServer)
27727
- // Display the state of the online attribute as-is
27728
- online = (_b = (_a = vnode.attrs) === null || _a === void 0 ? void 0 : _a.online) !== null && _b !== void 0 ? _b : false;
27729
- return m("span", {
27730
- id: id,
27731
- title: online ? ts("Er álínis") : ts("Álínis?"),
27732
- class: online ? "online" : ""
27733
- });
27734
- }
27735
- };
27736
28056
  };
27737
28057
  const UserId = {
27738
28058
  // User identifier at top right, opens user preferences
@@ -27905,11 +28225,12 @@ const TogglerReadyTimed = (initialVnode) => {
27905
28225
  }
27906
28226
  };
27907
28227
  };
27908
- const TogglerAudio = () => {
28228
+ const TogglerAudio = (initialVnode) => {
27909
28229
  // Toggle for audio on/off
28230
+ const { model } = initialVnode.attrs.view;
27910
28231
  function toggleFunc(state) {
27911
- if (state)
27912
- playAudio("your-turn");
28232
+ if (state && model.state !== null)
28233
+ playAudio(model.state, "your-turn");
27913
28234
  }
27914
28235
  return {
27915
28236
  view: ({ attrs: { state, tabindex } }) => m(Toggler, {
@@ -27924,11 +28245,12 @@ const TogglerAudio = () => {
27924
28245
  })
27925
28246
  };
27926
28247
  };
27927
- const TogglerFanfare = () => {
28248
+ const TogglerFanfare = (initialVnode) => {
27928
28249
  // Toggle for fanfare on/off
28250
+ const { model } = initialVnode.attrs.view;
27929
28251
  function toggleFunc(state) {
27930
- if (state)
27931
- playAudio("you-win");
28252
+ if (state && model.state !== null)
28253
+ playAudio(model.state, "you-win");
27932
28254
  }
27933
28255
  return {
27934
28256
  view: ({ attrs: { state, tabindex } }) => m(Toggler, {
@@ -27985,146 +28307,148 @@ const TogglerFairplay = () => {
27985
28307
  For further information, see https://github.com/mideind/Netskrafl
27986
28308
 
27987
28309
  */
27988
- const WaitDialog = (initialVnode) => {
28310
+ const WaitDialog = {
27989
28311
  // A dialog that is shown while the user waits for the opponent,
27990
28312
  // who issued a timed game challenge, to be ready
27991
- var _a, _b;
27992
- const attrs = initialVnode.attrs;
27993
- const view = attrs.view;
27994
- const model = view.model;
27995
- const state = model.state;
27996
- const duration = attrs.duration;
27997
- const oppId = attrs.oppId;
27998
- const key = attrs.challengeKey;
27999
- let oppNick = attrs.oppNick;
28000
- let oppName = attrs.oppName;
28001
- let oppOnline = false;
28002
- const userId = (_b = (_a = model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "";
28003
- // Firebase path
28004
- const path = 'user/' + userId + "/wait/" + oppId;
28005
- // Flag set when the new game has been initiated
28006
- let pointOfNoReturn = false;
28007
- let initialized = false;
28008
- async function updateOnline() {
28009
- // Initiate an online check on the opponent
28010
- try {
28011
- if (!oppId || !key || !state)
28012
- return;
28013
- const json = await request(state, {
28014
- method: "POST",
28015
- url: "/initwait",
28016
- body: { opp: oppId, key }
28017
- });
28018
- // If json.waiting is false, the initiation failed
28019
- // and there is really no point in continuing to wait
28020
- if (json && json.online && json.waiting)
28021
- // The user is online
28022
- oppOnline = true;
28023
- }
28024
- catch (e) {
28025
- }
28026
- }
28027
- async function cancelWait() {
28028
- // Cancel a pending wait for a timed game
28029
- if (!state)
28030
- return;
28031
- try {
28032
- await request(state, {
28033
- method: "POST",
28034
- url: "/cancelwait",
28035
- body: {
28036
- user: userId,
28037
- opp: oppId,
28038
- key
28039
- }
28040
- });
28041
- }
28042
- catch (e) {
28043
- }
28044
- }
28045
- const oncreate = async () => {
28046
- if (!userId || !oppId || initialized)
28313
+ oninit: (vnode) => {
28314
+ const { state } = vnode;
28315
+ state.oppOnline = false;
28316
+ state.pointOfNoReturn = false;
28317
+ state.firebasePath = "";
28318
+ },
28319
+ oncreate: (vnode) => {
28320
+ var _a, _b;
28321
+ // Note: we must use a two-step initialization here,
28322
+ // calling oninit() first and then oncreate(), since calls to
28323
+ // m.redraw() - explicit or implicit via m.request() - from within
28324
+ // oninit() will cause a recursive call to oninit(), resulting in
28325
+ // a double API call to the backend.
28326
+ const { attrs, state } = vnode;
28327
+ const { view, oppId, challengeKey: key } = attrs;
28328
+ const { model } = view;
28329
+ const globalState = model.state;
28330
+ const userId = (_b = (_a = model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "";
28331
+ if (!userId || !oppId)
28047
28332
  return; // Should not happen
28048
- initialized = true;
28049
- updateOnline();
28333
+ state.firebasePath = `user/${userId}/wait/${oppId}`;
28334
+ // Initiate an online check on the opponent (fire-and-forget)
28335
+ const updateOnline = async () => {
28336
+ try {
28337
+ if (key && globalState) {
28338
+ const json = await request(globalState, {
28339
+ method: "POST",
28340
+ url: "/initwait",
28341
+ body: { opp: oppId, key }
28342
+ });
28343
+ // If json.waiting is false, the initiation failed
28344
+ // and there is really no point in continuing to wait
28345
+ if (json && json.online && json.waiting) {
28346
+ // The user is online
28347
+ state.oppOnline = true;
28348
+ }
28349
+ }
28350
+ }
28351
+ catch (e) {
28352
+ }
28353
+ };
28354
+ updateOnline(); // Fire-and-forget, don't await
28050
28355
  // Attach a Firebase listener to the wait path
28051
- attachFirebaseListener(path, (json) => {
28356
+ attachFirebaseListener(state.firebasePath, (json) => {
28052
28357
  if (json !== true && json.game) {
28053
28358
  // A new game has been created and initiated by the server
28054
- pointOfNoReturn = true;
28055
- detachFirebaseListener(path);
28359
+ state.pointOfNoReturn = true;
28360
+ detachFirebaseListener(state.firebasePath);
28056
28361
  // We don't need to pop the dialog; that is done automatically
28057
28362
  // by the route resolver upon m.route.set()
28058
28363
  // Navigate to the newly initiated game
28059
28364
  m.route.set("/game/" + json.game);
28060
28365
  }
28061
28366
  });
28062
- };
28063
- return {
28064
- oncreate,
28065
- view: () => {
28066
- if (!state)
28067
- return null;
28068
- return m(".modal-dialog", { id: "wait-dialog", style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { "id": "wait-form" }, [
28069
- m(".chall-hdr", m("table", m("tbody", m("tr", [
28070
- m("td", m("h1.chall-icon", glyph("time"))),
28071
- m("td.l-border", [
28072
- m(OnlinePresence, { id: "chall-online", userId: oppId, online: oppOnline, state }),
28073
- m("h1", oppNick),
28074
- m("h2", oppName)
28075
- ])
28076
- ])))),
28077
- m(".wait-explain", [
28078
- mt("p", [
28079
- "Þú ert reiðubúin(n) að taka áskorun um viðureign með klukku, ",
28080
- m("strong", ["2 x ", duration.toString(), ts(" mínútur.")])
28081
- ]),
28082
- mt("p", [
28083
- "Beðið er eftir að áskorandinn ", m("strong", oppNick),
28084
- " sé ", oppOnline ? "" : mt("span#chall-is-online", "álínis og "), "til í tuskið."
28085
- ]),
28086
- mt("p", "Leikur hefst um leið og áskorandinn bregst við. Handahóf ræður hvor byrjar."),
28087
- mt("p", "Ef þér leiðist biðin geturðu hætt við og reynt aftur síðar.")
28367
+ },
28368
+ view: (vnode) => {
28369
+ var _a, _b;
28370
+ const { attrs, state } = vnode;
28371
+ const { view, oppId, duration, challengeKey: key, oppNick, oppName } = attrs;
28372
+ const { model } = view;
28373
+ const globalState = model.state;
28374
+ const userId = (_b = (_a = model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "";
28375
+ if (!globalState)
28376
+ return null;
28377
+ const cancelWait = async () => {
28378
+ // Cancel a pending wait for a timed game
28379
+ if (!globalState)
28380
+ return;
28381
+ try {
28382
+ request(globalState, {
28383
+ method: "POST",
28384
+ url: "/cancelwait",
28385
+ body: {
28386
+ user: userId,
28387
+ opp: oppId,
28388
+ key
28389
+ }
28390
+ });
28391
+ }
28392
+ catch (e) {
28393
+ }
28394
+ };
28395
+ return m(".modal-dialog", { id: "wait-dialog", style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { "id": "wait-form" }, [
28396
+ m(".chall-hdr", m("table", m("tbody", m("tr", [
28397
+ m("td", m("h1.chall-icon", glyph("time"))),
28398
+ m("td.l-border", [
28399
+ m(OnlinePresence, { id: "chall-online", userId: oppId, online: state.oppOnline, state: globalState }),
28400
+ m("h1", oppNick),
28401
+ m("h2", oppName),
28402
+ ])
28403
+ ])))),
28404
+ m(".wait-explain", [
28405
+ mt("p", [
28406
+ "Þú ert reiðubúin(n) að taka áskorun um viðureign með klukku,",
28088
28407
  ]),
28089
- m(DialogButton, {
28090
- id: "wait-cancel",
28091
- title: ts("Hætta við"),
28092
- onclick: (ev) => {
28093
- // Cancel the wait status and navigate back to the main page
28094
- if (pointOfNoReturn) {
28095
- // Actually, it's too late to cancel
28096
- ev.preventDefault();
28097
- return;
28098
- }
28099
- detachFirebaseListener(path);
28100
- cancelWait();
28101
- view.popDialog();
28408
+ m("p", [
28409
+ m("strong", ["2 x ", duration.toString(), ts(" mínútur.")])
28410
+ ]),
28411
+ mt("p", [
28412
+ "Beðið er eftir áskorandinn ", m("strong", oppNick),
28413
+ " sé ", state.oppOnline ? "" : mt("span#chall-is-online", "álínis og "), "til í tuskið."
28414
+ ]),
28415
+ mt("p", "Leikur hefst um leið og áskorandinn bregst við. Handahóf ræður hvor byrjar."),
28416
+ mt("p", "Ef þér leiðist biðin geturðu hætt við og reynt aftur síðar.")
28417
+ ]),
28418
+ m(DialogButton, {
28419
+ id: "wait-cancel",
28420
+ title: ts("Hætta við"),
28421
+ onclick: (ev) => {
28422
+ // Cancel the wait status and navigate back to the main page
28423
+ if (state.pointOfNoReturn) {
28424
+ // Actually, it's too late to cancel
28102
28425
  ev.preventDefault();
28426
+ return;
28103
28427
  }
28104
- }, glyph("remove"))
28105
- ]));
28106
- }
28107
- };
28428
+ if (state.firebasePath)
28429
+ detachFirebaseListener(state.firebasePath);
28430
+ cancelWait();
28431
+ view.popDialog();
28432
+ ev.preventDefault();
28433
+ }
28434
+ }, glyph("remove"))
28435
+ ]));
28436
+ }
28108
28437
  };
28109
- const AcceptDialog = (initialVnode) => {
28438
+ const AcceptDialog = {
28110
28439
  // A dialog that is shown (usually briefly) while
28111
28440
  // the user who originated a timed game challenge
28112
28441
  // is linked up with her opponent and a new game is started
28113
- const attrs = initialVnode.attrs;
28114
- const view = attrs.view;
28115
- const state = view.model.state;
28116
- const oppId = attrs.oppId;
28117
- const key = attrs.challengeKey;
28118
- let oppNick = attrs.oppNick;
28119
- let oppReady = true;
28120
- let loading = false;
28121
- async function waitCheck() {
28442
+ oninit: async (vnode) => {
28443
+ const { attrs, state } = vnode;
28444
+ const { view, oppId, challengeKey: key } = attrs;
28445
+ const globalState = view.model.state;
28446
+ if (!globalState)
28447
+ return; // Should not happen
28448
+ state.oppReady = true;
28122
28449
  // Initiate a wait status check on the opponent
28123
- if (loading || !state)
28124
- return; // Already checking
28125
- loading = true;
28126
28450
  try {
28127
- const json = await request(state, {
28451
+ const json = await request(globalState, {
28128
28452
  method: "POST",
28129
28453
  url: "/waitcheck",
28130
28454
  body: { user: oppId, key }
@@ -28135,42 +28459,40 @@ const AcceptDialog = (initialVnode) => {
28135
28459
  // and all open dialogs are thereby closed automatically.
28136
28460
  view.actions.startNewGame(oppId, true);
28137
28461
  }
28138
- else
28462
+ else {
28139
28463
  // Something didn't check out: keep the dialog open
28140
28464
  // until the user manually closes it
28141
- oppReady = false;
28465
+ state.oppReady = false;
28466
+ }
28142
28467
  }
28143
28468
  catch (e) {
28469
+ state.oppReady = false;
28144
28470
  }
28145
- finally {
28146
- loading = false;
28147
- }
28471
+ },
28472
+ view: (vnode) => {
28473
+ const { attrs, state } = vnode;
28474
+ const { view, oppNick } = attrs;
28475
+ return m(".modal-dialog", { id: "accept-dialog", style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { id: "accept-form" }, [
28476
+ m(".chall-hdr", m("table", m("tbody", m("tr", [
28477
+ m("td", m("h1.chall-icon", glyph("time"))),
28478
+ m("td.l-border", m("h1", oppNick))
28479
+ ])))),
28480
+ m("div", { "style": { "text-align": "center", "padding-top": "32px" } }, [
28481
+ m("p", mt("strong", "Viðureign með klukku")),
28482
+ mt("p", state.oppReady ? "Athuga hvort andstæðingur er reiðubúinn..."
28483
+ : ["Andstæðingurinn ", m("strong", oppNick), " er ekki reiðubúinn"])
28484
+ ]),
28485
+ m(DialogButton, {
28486
+ id: 'accept-cancel',
28487
+ title: ts('Reyna síðar'),
28488
+ onclick: (ev) => {
28489
+ // Abort mission
28490
+ view.popDialog();
28491
+ ev.preventDefault();
28492
+ }
28493
+ }, glyph("remove"))
28494
+ ]));
28148
28495
  }
28149
- return {
28150
- oninit: waitCheck,
28151
- view: () => {
28152
- return m(".modal-dialog", { id: "accept-dialog", style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { id: "accept-form" }, [
28153
- m(".chall-hdr", m("table", m("tbody", m("tr", [
28154
- m("td", m("h1.chall-icon", glyph("time"))),
28155
- m("td.l-border", m("h1", oppNick))
28156
- ])))),
28157
- m("div", { "style": { "text-align": "center", "padding-top": "32px" } }, [
28158
- m("p", mt("strong", "Viðureign með klukku")),
28159
- mt("p", oppReady ? "Athuga hvort andstæðingur er reiðubúinn..."
28160
- : ["Andstæðingurinn ", m("strong", oppNick), " er ekki reiðubúinn"])
28161
- ]),
28162
- m(DialogButton, {
28163
- id: 'accept-cancel',
28164
- title: ts('Reyna síðar'),
28165
- onclick: (ev) => {
28166
- // Abort mission
28167
- view.popDialog();
28168
- ev.preventDefault();
28169
- }
28170
- }, glyph("remove"))
28171
- ]));
28172
- }
28173
- };
28174
28496
  };
28175
28497
 
28176
28498
  /*
@@ -29075,8 +29397,7 @@ class Game extends BaseGame {
29075
29397
  // Ongoing timed game: start the clock
29076
29398
  this.startClock();
29077
29399
  // Kick off loading of chat messages, if this is not a robot game
29078
- const isHumanGame = !this.autoplayer[0] && !this.autoplayer[1];
29079
- if (isHumanGame)
29400
+ if (!this.isRobotGame())
29080
29401
  this.loadMessages();
29081
29402
  }
29082
29403
  init(srvGame) {
@@ -29234,6 +29555,8 @@ class Game extends BaseGame {
29234
29555
  // Update the srvGame state with data from the server,
29235
29556
  // either after submitting a move to the server or
29236
29557
  // after receiving a move notification via the Firebase listener
29558
+ // Remember if the game was already won before this update
29559
+ const wasWon = this.congratulate;
29237
29560
  // Stop highlighting the previous opponent move, if any
29238
29561
  for (let sq in this.tiles)
29239
29562
  if (this.tiles.hasOwnProperty(sq))
@@ -29254,6 +29577,10 @@ class Game extends BaseGame {
29254
29577
  // The call to resetClock() clears any outstanding interval timers
29255
29578
  // if the srvGame is now over
29256
29579
  this.resetClock();
29580
+ // Notify the move listener if the game just transitioned to won
29581
+ if (!wasWon && this.congratulate && this.moveListener) {
29582
+ this.moveListener.notifyGameWon();
29583
+ }
29257
29584
  }
29258
29585
  ;
29259
29586
  async refresh() {
@@ -29458,6 +29785,10 @@ class Game extends BaseGame {
29458
29785
  // actual game score minus accrued time penalty, if any, in a timed game
29459
29786
  return Math.max(this.scores[player] + (player === 0 ? this.penalty0 : this.penalty1), 0);
29460
29787
  }
29788
+ isRobotGame() {
29789
+ // Return true if any player in the game is a robot
29790
+ return this.autoplayer[0] || this.autoplayer[1];
29791
+ }
29461
29792
  async loadMessages() {
29462
29793
  // Load chat messages for this game
29463
29794
  if (this.chatLoading)
@@ -30942,60 +31273,48 @@ class Model {
30942
31273
  }
30943
31274
  return false;
30944
31275
  }
30945
- handleUserMessage(json, firstAttach) {
30946
- // Handle an incoming Firebase user message, i.e. a message
30947
- // on the /user/[userid] path
31276
+ handleUserChallengeMessage(json, firstAttach) {
31277
+ // Handle an incoming Firebase user challenge message,
31278
+ // i.e. a message on the /user/[userid]/challenge path
30948
31279
  if (firstAttach || !this.state || !json)
30949
31280
  return;
30950
- let redraw = false;
30951
- if (typeof json.plan === "string") {
30952
- // Potential change of user subscription plan
30953
- if (this.state.plan !== json.plan) {
30954
- this.state.plan = json.plan;
30955
- redraw = true;
30956
- }
30957
- }
30958
- if (json.hasPaid !== undefined) {
30959
- // Potential change of payment status
30960
- const newHasPaid = (this.state.plan !== "" && json.hasPaid) ? true : false;
30961
- if (this.state.hasPaid !== newHasPaid) {
30962
- this.state.hasPaid = newHasPaid;
30963
- redraw = true;
30964
- }
30965
- }
30966
- let invalidateGameList = false;
30967
- // The following code is a bit iffy since both json.challenge and json.move
30968
- // are included in the same message on the /user/[userid] path.
30969
- // !!! FIXME: Split this into two separate listeners,
30970
- // !!! one for challenges and one for moves
30971
- if (json.challenge) {
30972
- // Reload challenge list
30973
- this.loadChallengeList();
30974
- if (this.userListCriteria)
30975
- // We are showing a user list: reload it
30976
- this.loadUserList(this.userListCriteria);
30977
- // Reload game list
30978
- // !!! FIXME: It is strictly speaking not necessary to reload
30979
- // !!! the game list unless this is an acceptance of a challenge
30980
- // !!! (issuance or rejection don't cause the game list to change)
30981
- invalidateGameList = true;
30982
- }
30983
- else if (json.move) {
30984
- // A move has been made in one of this user's games:
30985
- // invalidate the game list (will be loaded upon next display)
30986
- invalidateGameList = true;
30987
- }
30988
- if (invalidateGameList && !this.loadingGameList) {
31281
+ // Reload challenge list
31282
+ this.loadChallengeList();
31283
+ if (this.userListCriteria)
31284
+ // We are showing a user list: reload it
31285
+ this.loadUserList(this.userListCriteria);
31286
+ // Reload game list
31287
+ // !!! FIXME: It is strictly speaking not necessary to reload
31288
+ // !!! the game list unless this is an acceptance of a challenge
31289
+ // !!! (issuance or rejection don't cause the game list to change)
31290
+ if (!this.loadingGameList) {
30989
31291
  this.gameList = null;
30990
- redraw = true;
30991
31292
  }
30992
- if (redraw)
31293
+ m.redraw();
31294
+ }
31295
+ handleUserMoveMessage(json, firstAttach) {
31296
+ // Handle an incoming Firebase user move message,
31297
+ // i.e. a message on the /user/[userid]/move path
31298
+ if (firstAttach || !this.state || !json)
31299
+ return;
31300
+ // A move has been made in one of this user's games:
31301
+ // invalidate the game list (will be loaded upon next display)
31302
+ if (!this.loadingGameList) {
31303
+ this.gameList = null;
30993
31304
  m.redraw();
31305
+ }
30994
31306
  }
30995
31307
  handleMoveMessage(json, firstAttach) {
30996
31308
  // Handle an incoming Firebase move message
30997
31309
  if (!firstAttach && this.game) {
30998
31310
  this.game.update(json);
31311
+ // Play "your turn" audio notification if:
31312
+ // - User has audio enabled
31313
+ // - User is a participant in the game
31314
+ // - This is not a robot game (robots reply instantly anyway)
31315
+ if (this.state.audio && this.game.player !== null && !this.game.isRobotGame()) {
31316
+ playAudio(this.state, "your-turn");
31317
+ }
30999
31318
  m.redraw();
31000
31319
  }
31001
31320
  }
@@ -31006,6 +31325,14 @@ class Model {
31006
31325
  this.gameList = null;
31007
31326
  }
31008
31327
  }
31328
+ notifyGameWon() {
31329
+ var _a;
31330
+ // The user just won a game:
31331
+ // play the "you-win" audio if fanfare is enabled
31332
+ if ((_a = this.user) === null || _a === void 0 ? void 0 : _a.fanfare) {
31333
+ playAudio(this.state, "you-win");
31334
+ }
31335
+ }
31009
31336
  moreGamesAllowed() {
31010
31337
  // Return true if the user is allowed to have more games ongoing
31011
31338
  if (!this.state)
@@ -32428,117 +32755,128 @@ const Main = () => {
32428
32755
  For further information, see https://github.com/mideind/Netskrafl
32429
32756
 
32430
32757
  */
32431
- const UserInfoDialog = (initialVnode) => {
32758
+ const _updateStats = (attrs, state) => {
32759
+ // Fetch the statistics of the given user
32760
+ if (state.loadingStats)
32761
+ return;
32762
+ state.loadingStats = true;
32763
+ const { model } = attrs.view;
32764
+ model.loadUserStats(attrs.userid, (json) => {
32765
+ if (json && json.result === 0)
32766
+ state.stats = json;
32767
+ else
32768
+ state.stats = {};
32769
+ state.loadingStats = false;
32770
+ // m.redraw();
32771
+ });
32772
+ };
32773
+ const _updateRecentList = (attrs, state) => {
32774
+ var _a, _b;
32775
+ // Fetch the recent game list of the given user
32776
+ if (state.loadingRecentList)
32777
+ return;
32778
+ state.loadingRecentList = true;
32779
+ const { model } = attrs.view;
32780
+ model.loadUserRecentList(attrs.userid, state.versusAll ? null : (_b = (_a = model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "", (json) => {
32781
+ if (json && json.result === 0)
32782
+ state.recentList = json.recentlist;
32783
+ else
32784
+ state.recentList = [];
32785
+ state.loadingRecentList = false;
32786
+ // m.redraw();
32787
+ });
32788
+ };
32789
+ const _setVersus = (attrs, state, vsState) => {
32790
+ if (state.versusAll != vsState) {
32791
+ state.versusAll = vsState;
32792
+ state.loadingRecentList = false;
32793
+ _updateRecentList(attrs, state);
32794
+ }
32795
+ };
32796
+ const UserInfoDialog = {
32432
32797
  // A dialog showing the track record of a given user, including
32433
32798
  // recent games and total statistics
32799
+ /*
32434
32800
  const view = initialVnode.attrs.view;
32435
32801
  const model = view.model;
32436
- let stats = {};
32437
- let recentList = [];
32802
+ let stats: UserStats = {};
32803
+ let recentList: RecentListItem[] = [];
32438
32804
  let versusAll = true; // Show games against all opponents or just the current user?
32439
32805
  let loadingStats = false;
32440
32806
  let loadingRecentList = false;
32441
- function _updateStats(vnode) {
32442
- // Fetch the statistics of the given user
32443
- if (loadingStats)
32444
- return;
32445
- loadingStats = true;
32446
- model.loadUserStats(vnode.attrs.userid, (json) => {
32447
- if (json && json.result === 0)
32448
- stats = json;
32449
- else
32450
- stats = {};
32451
- loadingStats = false;
32452
- // m.redraw();
32453
- });
32454
- }
32455
- function _updateRecentList(vnode) {
32456
- var _a, _b;
32457
- // Fetch the recent game list of the given user
32458
- if (loadingRecentList)
32459
- return;
32460
- loadingRecentList = true;
32461
- model.loadUserRecentList(vnode.attrs.userid, versusAll ? null : (_b = (_a = model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "", (json) => {
32462
- if (json && json.result === 0)
32463
- recentList = json.recentlist;
32464
- else
32465
- recentList = [];
32466
- loadingRecentList = false;
32467
- // m.redraw();
32468
- });
32469
- }
32470
- function _setVersus(vnode, vsState) {
32471
- if (versusAll != vsState) {
32472
- versusAll = vsState;
32473
- loadingRecentList = false;
32474
- _updateRecentList(vnode);
32475
- }
32476
- }
32477
- return {
32478
- oninit: (vnode) => {
32479
- _updateRecentList(vnode);
32480
- _updateStats(vnode);
32481
- },
32482
- view: (vnode) => {
32483
- return m(".modal-dialog", { id: 'usr-info-dialog', style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { id: 'usr-info-form' }, [
32484
- m(".usr-info-hdr", [
32485
- m("h1.usr-info-icon", [
32486
- stats.friend ?
32487
- glyph("coffee-cup", { title: ts('Áskrifandi') }) :
32488
- glyph("user"), nbsp()
32489
- ]),
32490
- m("h1[id='usr-info-nick']", vnode.attrs.nick),
32491
- m("span.vbar", "|"),
32492
- m("h2[id='usr-info-fullname']", vnode.attrs.fullname),
32493
- m(".usr-info-fav", {
32494
- title: ts('Uppáhald'),
32495
- onclick: (ev) => {
32496
- var _a;
32497
- // Toggle the favorite setting
32498
- ev.preventDefault();
32499
- view.actions.toggleFavorite(vnode.attrs.userid, (_a = stats.favorite) !== null && _a !== void 0 ? _a : false);
32500
- stats.favorite = !stats.favorite;
32501
- }
32502
- }, stats.favorite ? glyph("star") : glyph("star-empty"))
32503
- ]),
32504
- m("p", [
32505
- m("strong", t("Nýjustu viðureignir")),
32506
- nbsp(),
32507
- m("span.versus-cat", [
32508
- m("span", {
32509
- class: versusAll ? "shown" : "",
32510
- onclick: () => { _setVersus(vnode, true); } // Set this.versusAll to true
32511
- }, t(" gegn öllum ")),
32512
- m("span", {
32513
- class: versusAll ? "" : "shown",
32514
- onclick: () => { _setVersus(vnode, false); } // Set this.versusAll to false
32515
- }, t(" gegn þér "))
32516
- ])
32807
+ */
32808
+ oninit: (vnode) => {
32809
+ const { attrs, state } = vnode;
32810
+ state.stats = {};
32811
+ state.recentList = [];
32812
+ state.versusAll = true;
32813
+ state.loadingRecentList = false;
32814
+ state.loadingStats = false;
32815
+ _updateRecentList(attrs, state);
32816
+ _updateStats(attrs, state);
32817
+ },
32818
+ view: (vnode) => {
32819
+ const { attrs, state } = vnode;
32820
+ const { view } = attrs;
32821
+ const { stats, recentList, versusAll } = state;
32822
+ return m(".modal-dialog", { id: 'usr-info-dialog', style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { id: 'usr-info-form' }, [
32823
+ m(".usr-info-hdr", [
32824
+ m("h1.usr-info-icon", [
32825
+ vnode.state.stats.friend ?
32826
+ glyph("coffee-cup", { title: ts('Áskrifandi') }) :
32827
+ glyph("user"), nbsp()
32517
32828
  ]),
32518
- m(".listitem.listheader", [
32519
- m("span.list-win", glyphGrayed("bookmark", { title: ts('Sigur') })),
32520
- mt("span.list-ts-short", "Viðureign lauk"),
32521
- mt("span.list-nick", "Andstæðingur"),
32522
- mt("span.list-scorehdr", "Úrslit"),
32523
- m("span.list-elo-hdr", [
32524
- m("span.glyphicon.glyphicon-user.elo-hdr-left", { title: ts('Mennskir andstæðingar') }),
32525
- "Elo",
32526
- m("span.glyphicon.glyphicon-cog.elo-hdr-right", { title: ts('Allir andstæðingar') })
32527
- ]),
32528
- mt("span.list-duration", "Lengd"),
32529
- m("span.list-manual", glyphGrayed("lightbulb", { title: ts('Keppnishamur') }))
32829
+ m("h1[id='usr-info-nick']", vnode.attrs.nick),
32830
+ m("span.vbar", "|"),
32831
+ m("h2[id='usr-info-fullname']", vnode.attrs.fullname),
32832
+ m(".usr-info-fav", {
32833
+ title: ts('Uppáhald'),
32834
+ onclick: (ev) => {
32835
+ var _a;
32836
+ // Toggle the favorite setting
32837
+ ev.preventDefault();
32838
+ view.actions.toggleFavorite(vnode.attrs.userid, (_a = stats.favorite) !== null && _a !== void 0 ? _a : false);
32839
+ stats.favorite = !stats.favorite;
32840
+ }
32841
+ }, stats.favorite ? glyph("star") : glyph("star-empty"))
32842
+ ]),
32843
+ m("p", [
32844
+ m("strong", t("Nýjustu viðureignir")),
32845
+ nbsp(),
32846
+ m("span.versus-cat", [
32847
+ m("span", {
32848
+ class: versusAll ? "shown" : "",
32849
+ onclick: () => { _setVersus(attrs, state, true); } // Set this.versusAll to true
32850
+ }, t(" gegn öllum ")),
32851
+ m("span", {
32852
+ class: versusAll ? "" : "shown",
32853
+ onclick: () => { _setVersus(attrs, state, false); } // Set this.versusAll to false
32854
+ }, t(" gegn þér "))
32855
+ ])
32856
+ ]),
32857
+ m(".listitem.listheader", [
32858
+ m("span.list-win", glyphGrayed("bookmark", { title: ts('Sigur') })),
32859
+ mt("span.list-ts-short", "Viðureign lauk"),
32860
+ mt("span.list-nick", "Andstæðingur"),
32861
+ mt("span.list-scorehdr", "Úrslit"),
32862
+ m("span.list-elo-hdr", [
32863
+ m("span.glyphicon.glyphicon-user.elo-hdr-left", { title: ts('Mennskir andstæðingar') }),
32864
+ "Elo",
32865
+ m("span.glyphicon.glyphicon-cog.elo-hdr-right", { title: ts('Allir andstæðingar') })
32530
32866
  ]),
32531
- m(RecentList, { view, id: 'usr-recent', recentList }), // Recent game list
32532
- m(StatsDisplay, { view, id: 'usr-stats', ownStats: stats }),
32533
- m(BestDisplay, { id: 'usr-best', ownStats: stats, myself: false }), // Highest word and game scores
32534
- m(DialogButton, {
32535
- id: 'usr-info-close',
32536
- title: ts('Loka'),
32537
- onclick: (ev) => { view.popDialog(); ev.preventDefault(); }
32538
- }, glyph("ok"))
32539
- ]));
32540
- }
32541
- };
32867
+ mt("span.list-duration", "Lengd"),
32868
+ m("span.list-manual", glyphGrayed("lightbulb", { title: ts('Keppnishamur') }))
32869
+ ]),
32870
+ m(RecentList, { view, id: 'usr-recent', recentList }), // Recent game list
32871
+ m(StatsDisplay, { view, id: 'usr-stats', ownStats: stats }),
32872
+ m(BestDisplay, { id: 'usr-best', ownStats: stats, myself: false }), // Highest word and game scores
32873
+ m(DialogButton, {
32874
+ id: 'usr-info-close',
32875
+ title: ts('Loka'),
32876
+ onclick: (ev) => { view.popDialog(); ev.preventDefault(); }
32877
+ }, glyph("ok"))
32878
+ ]));
32879
+ },
32542
32880
  };
32543
32881
 
32544
32882
  /*
@@ -32717,7 +33055,7 @@ const GamePromptDialogs = (initialVnode) => {
32717
33055
  // they can be invoked while the last_chall dialog is being
32718
33056
  // displayed. We therefore allow them to cover the last_chall
32719
33057
  // dialog. On mobile, both dialogs are displayed simultaneously.
32720
- if (game.last_chall) {
33058
+ if (game.last_chall && game.localturn) {
32721
33059
  r.push(m(".chall-info", [
32722
33060
  glyph("info-sign"), nbsp(),
32723
33061
  // "Your opponent emptied the rack - you can challenge or pass"
@@ -34334,7 +34672,7 @@ const Tab = {
34334
34672
  const game = view.model.game;
34335
34673
  return m(".right-tab" + (sel === tabid ? ".selected" : ""), {
34336
34674
  id: "tab-" + tabid,
34337
- className: alert ? "alert" : "",
34675
+ className: alert ? "chat-alert" : "",
34338
34676
  title: title,
34339
34677
  onclick: (ev) => {
34340
34678
  // Select this tab
@@ -34356,7 +34694,7 @@ const TabGroup = {
34356
34694
  // A group of clickable tabs for the right-side area content
34357
34695
  const { view } = vnode.attrs;
34358
34696
  const { game } = view.model;
34359
- const showChat = game && !(game.autoplayer[0] || game.autoplayer[1]);
34697
+ const showChat = game && !game.isRobotGame();
34360
34698
  const r = [
34361
34699
  m(Tab, { view, tabid: "board", title: ts("Borðið"), icon: "grid" }),
34362
34700
  m(Tab, { view, tabid: "movelist", title: ts("Leikir"), icon: "show-lines" }),
@@ -34378,7 +34716,7 @@ const TabGroup = {
34378
34716
  },
34379
34717
  // Show chat icon in red if any chat messages have not been seen
34380
34718
  // and the chat tab is not already selected
34381
- alert: !game.chatSeen && view.selectedTab != "chat"
34719
+ alert: !game.chatSeen && view.selectedTab !== "chat"
34382
34720
  }));
34383
34721
  }
34384
34722
  return m.fragment({}, r);
@@ -35080,6 +35418,18 @@ class View {
35080
35418
  this.actions = actions;
35081
35419
  // Initialize media listeners now that we have the view reference
35082
35420
  this.actions.initMediaListener(this);
35421
+ // Set up callback to attach user listener when Firebase is ready
35422
+ // This handles both fresh login and cached authentication cases
35423
+ const state = this.model.state;
35424
+ if (state) {
35425
+ state.onFirebaseReady = () => {
35426
+ // Firebase Database is now initialized, attach user listener
35427
+ this.actions.attachListenerToUser();
35428
+ };
35429
+ }
35430
+ // Load user preferences early so audio settings are available
35431
+ // Use false to not show spinner on initial load
35432
+ this.model.loadUser(false);
35083
35433
  }
35084
35434
  appView(routeName) {
35085
35435
  // Returns a view based on the current route.
@@ -35412,8 +35762,8 @@ View.dialogViews = {
35412
35762
  class Actions {
35413
35763
  constructor(model) {
35414
35764
  this.model = model;
35415
- // Media listeners will be initialized when view is available
35416
- // this.attachListenerToUser();
35765
+ // Media and Firebase listeners will be initialized
35766
+ // when view is available
35417
35767
  }
35418
35768
  onNavigateTo(routeName, params, view) {
35419
35769
  var _a, _b;
@@ -35503,23 +35853,36 @@ class Actions {
35503
35853
  }
35504
35854
  onMoveMessage(json, firstAttach) {
35505
35855
  // Handle a move message from Firebase
35506
- console.log("Move message received: " + JSON.stringify(json));
35856
+ // console.log("Move message received: " + JSON.stringify(json));
35507
35857
  this.model.handleMoveMessage(json, firstAttach);
35508
35858
  }
35509
- onUserMessage(json, firstAttach) {
35510
- // Handle a user message from Firebase
35511
- console.log("User message received: " + JSON.stringify(json));
35512
- this.model.handleUserMessage(json, firstAttach);
35859
+ onUserChallengeMessage(json, firstAttach) {
35860
+ // Handle a user challenge message from Firebase
35861
+ // console.log("User challenge message received: " + JSON.stringify(json));
35862
+ this.model.handleUserChallengeMessage(json, firstAttach);
35863
+ }
35864
+ onUserMoveMessage(json, firstAttach) {
35865
+ // Handle a user move message from Firebase
35866
+ // console.log("User move message received: " + JSON.stringify(json));
35867
+ this.model.handleUserMoveMessage(json, firstAttach);
35513
35868
  }
35514
35869
  onChatMessage(json, firstAttach, view) {
35870
+ var _a;
35515
35871
  // Handle an incoming chat message
35516
- if (firstAttach)
35517
- console.log("First attach of chat: " + JSON.stringify(json));
35872
+ if (firstAttach) ;
35518
35873
  else {
35519
- console.log("Chat message received: " + JSON.stringify(json));
35874
+ // console.log("Chat message received: " + JSON.stringify(json));
35520
35875
  if (this.model.addChatMessage(json.game, json.from_userid, json.msg, json.ts)) {
35521
35876
  // A chat message was successfully added
35522
35877
  view.notifyChatMessage();
35878
+ // Play audio notification if:
35879
+ // - User has audio enabled
35880
+ // - Message is from opponent (not from current user)
35881
+ const { state } = this.model;
35882
+ const userId = (_a = state.userId) !== null && _a !== void 0 ? _a : "";
35883
+ if (state.audio && json.from_userid !== userId) {
35884
+ playAudio(state, "new-msg");
35885
+ }
35523
35886
  }
35524
35887
  }
35525
35888
  }
@@ -35620,12 +35983,17 @@ class Actions {
35620
35983
  }
35621
35984
  attachListenerToUser() {
35622
35985
  const state = this.model.state;
35623
- if (state && state.userId)
35624
- attachFirebaseListener('user/' + state.userId, (json, firstAttach) => this.onUserMessage(json, firstAttach));
35986
+ // console.log(`attachListenerToUser(): userId=${state?.userId}`);
35987
+ if (!state || !state.userId)
35988
+ return;
35989
+ // Listen to challenge and move notifications separately
35990
+ attachFirebaseListener(`user/${state.userId}/challenge`, (json, firstAttach) => this.onUserChallengeMessage(json, firstAttach));
35991
+ attachFirebaseListener(`user/${state.userId}/move`, (json, firstAttach) => this.onUserMoveMessage(json, firstAttach));
35625
35992
  }
35626
35993
  detachListenerFromUser() {
35627
35994
  // Stop listening to Firebase notifications for the current user
35628
35995
  const state = this.model.state;
35996
+ // console.log(`detachListenerFromUser(): userId=${state?.userId}`);
35629
35997
  if (state && state.userId)
35630
35998
  detachFirebaseListener('user/' + state.userId);
35631
35999
  }
@@ -37217,7 +37585,7 @@ async function main(state, container) {
37217
37585
  const locale = state.locale || "is_IS";
37218
37586
  // Log the date being used (helpful for debugging)
37219
37587
  if (dateParam) {
37220
- console.log(`Loading Gáta Dagsins for date: ${validDate} (from URL parameter)`);
37588
+ // console.log(`Loading Gáta Dagsins for date: ${validDate} (from URL parameter)`);
37221
37589
  }
37222
37590
  // Mount the Gáta Dagsins UI using an anonymous closure component
37223
37591
  m.mount(container, {