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