anear-js-api 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -166,7 +166,9 @@ const AnearEventMachineContext = (
166
166
  suppressParticipantTimeouts: false, // Flag to suppress PARTICIPANT_TIMEOUT events (cleared when new timers are set up via render)
167
167
  eachParticipantCancelActionType: null, // ACTION type that triggers cancellation for eachParticipant timeouts
168
168
  pendingCancelConfirmations: null, // Set of participantIds waiting for TIMER_CANCELED confirmation
169
- pendingCancelAction: null // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
169
+ pendingCancelAction: null, // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
170
+ consecutiveTimeoutCount: 0, // Global counter tracking consecutive timeouts across all participants for dead-man switch
171
+ consecutiveAllParticipantsTimeoutCount: 0 // Counter tracking consecutive allParticipants timeouts where ALL participants timed out
170
172
  })
171
173
 
172
174
  const ActiveEventGlobalEvents = {
@@ -278,7 +280,7 @@ const ActiveEventStatesConfig = {
278
280
  ],
279
281
  on: {
280
282
  ACTION: {
281
- actions: ['processParticipantAction']
283
+ actions: ['resetParticipantTimeoutCount', 'processParticipantAction']
282
284
  },
283
285
  RENDER_DISPLAY: {
284
286
  target: '#createdRendering'
@@ -447,7 +449,7 @@ const ActiveEventStatesConfig = {
447
449
  initial: 'transitioning',
448
450
  on: {
449
451
  ACTION: {
450
- actions: ['processParticipantAction']
452
+ actions: ['resetParticipantTimeoutCount', 'processParticipantAction']
451
453
  },
452
454
  RENDER_DISPLAY: {
453
455
  target: '#announceRendering'
@@ -629,6 +631,11 @@ const ActiveEventStatesConfig = {
629
631
  },
630
632
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
631
633
  actions: 'cancelAllParticipantTimeouts'
634
+ },
635
+ // Handle PARTICIPANT_TIMEOUT even when in liveRendering state
636
+ // (events bubble up from child states, so we need to handle them here)
637
+ PARTICIPANT_TIMEOUT: {
638
+ actions: ['trackParticipantTimeout', 'processParticipantTimeout']
632
639
  }
633
640
  },
634
641
  states: {
@@ -677,11 +684,11 @@ const ActiveEventStatesConfig = {
677
684
  },
678
685
  on: {
679
686
  ACTION: {
680
- actions: ['processParticipantAction']
687
+ actions: ['resetParticipantTimeoutCount', 'processParticipantAction']
681
688
  },
682
689
  // Forward per-participant timeouts (from APM) to the AppM
683
690
  PARTICIPANT_TIMEOUT: {
684
- actions: ['processParticipantTimeout'],
691
+ actions: ['trackParticipantTimeout', 'processParticipantTimeout'],
685
692
  // v5 note: internal transitions are the default (v4 had `internal: true`)
686
693
  },
687
694
  TRIGGER_CANCEL_SEQUENCE: {
@@ -735,7 +742,7 @@ const ActiveEventStatesConfig = {
735
742
  always: {
736
743
  guard: 'allParticipantsResponded',
737
744
  target: 'waitingForActions',
738
- actions: ['clearParticipantsTimeout']
745
+ actions: ['sendActionsCompleteToAppM', 'clearParticipantsTimeout']
739
746
  },
740
747
  after: {
741
748
  participantsActionTimeout: {
@@ -794,7 +801,7 @@ const ActiveEventStatesConfig = {
794
801
  target: 'handleParticipantsTimeoutCanceled'
795
802
  },
796
803
  {
797
- actions: ['processAndForwardAction', 'processParticipantResponse'],
804
+ actions: ['resetParticipantTimeoutCount', 'processAndForwardAction', 'processParticipantResponse'],
798
805
  // v5 note: internal transitions are the default (v4 had `internal: true`)
799
806
  }
800
807
  ],
@@ -1831,6 +1838,20 @@ const AnearEventMachineFunctions = ({
1831
1838
  const eventName = getPresenceEventName(participant, 'UPDATE');
1832
1839
  context.appEventMachine.send({ type: eventName, data: event.data });
1833
1840
  },
1841
+ resetParticipantTimeoutCount: assign(({ context, event }) => {
1842
+ const participantId = event.data.participantId
1843
+ // Reset global consecutive timeout counts when ANY participant sends an action
1844
+ const updates = {}
1845
+ if (context.consecutiveTimeoutCount > 0) {
1846
+ logger.debug(`[AEM] Resetting global timeout count from ${context.consecutiveTimeoutCount} to 0 (action received from ${participantId})`)
1847
+ updates.consecutiveTimeoutCount = 0
1848
+ }
1849
+ if (context.consecutiveAllParticipantsTimeoutCount > 0) {
1850
+ logger.debug(`[AEM] Resetting allParticipants timeout count from ${context.consecutiveAllParticipantsTimeoutCount} to 0 (action received from ${participantId})`)
1851
+ updates.consecutiveAllParticipantsTimeoutCount = 0
1852
+ }
1853
+ return updates
1854
+ }),
1834
1855
  processParticipantAction: ({ context, event, self }) => {
1835
1856
  // event.data.participantId,
1836
1857
  // event.data.payload: {"appEventMachineACTION": {action event keys and values}}
@@ -1916,7 +1937,18 @@ const AnearEventMachineFunctions = ({
1916
1937
  }
1917
1938
  }
1918
1939
  }),
1919
- processParticipantTimeout: ({ context, event }) => {
1940
+ trackParticipantTimeout: assign(({ context, event }) => {
1941
+ const participantId = event.participantId
1942
+ const currentCount = context.consecutiveTimeoutCount || 0
1943
+ const newCount = currentCount + 1
1944
+
1945
+ logger.debug(`[AEM] Global timeout count (any participant): ${currentCount} -> ${newCount} (timeout from ${participantId})`)
1946
+
1947
+ return {
1948
+ consecutiveTimeoutCount: newCount
1949
+ }
1950
+ }),
1951
+ processParticipantTimeout: ({ context, event, self }) => {
1920
1952
  // Suppress PARTICIPANT_TIMEOUT events after cancelAllParticipantTimeouts() until new timers
1921
1953
  // are set up via render. This handles race conditions where an APM's timer fires just
1922
1954
  // before receiving CANCEL_TIMEOUT. The flag is cleared when renders complete (new timers set up).
@@ -1924,7 +1956,20 @@ const AnearEventMachineFunctions = ({
1924
1956
  logger.debug(`[AEM] Suppressing PARTICIPANT_TIMEOUT from ${event.participantId} (cancelled timers, waiting for new render)`)
1925
1957
  return
1926
1958
  }
1927
- context.appEventMachine.send({ type: 'PARTICIPANT_TIMEOUT', participantId: event.participantId })
1959
+
1960
+ const participantId = event.participantId
1961
+ const timeoutCount = context.consecutiveTimeoutCount || 0
1962
+ const maxConsecutiveTimeouts = C.DEAD_MAN_SWITCH.MAX_CONSECUTIVE_PARTICIPANT_TIMEOUTS
1963
+
1964
+ // Check dead-man switch: if global consecutive timeout threshold reached, cancel event
1965
+ // This detects when the game is "dead" (no actions happening) across all participants
1966
+ if (timeoutCount >= maxConsecutiveTimeouts) {
1967
+ logger.warn(`[AEM] Dead-man switch triggered: ${timeoutCount} consecutive timeouts across all participants (threshold: ${maxConsecutiveTimeouts}). Auto-canceling event.`)
1968
+ self.send({ type: 'CANCEL' })
1969
+ return
1970
+ }
1971
+
1972
+ context.appEventMachine.send({ type: 'PARTICIPANT_TIMEOUT', participantId })
1928
1973
  },
1929
1974
  setupParticipantsTimeout: assign(({ context, event }) => {
1930
1975
  // Only set up a new timeout if one is provided by the display render workflow
@@ -2001,21 +2046,70 @@ const AnearEventMachineFunctions = ({
2001
2046
  }
2002
2047
  };
2003
2048
  }),
2004
- sendActionsTimeoutToAppM: ({ context }) => {
2049
+ sendActionsCompleteToAppM: assign(({ context }) => {
2050
+ // When all participants respond, the final ACTION already has finalAction: true
2051
+ // No need to send a separate ACTIONS_COMPLETE event - the finalAction flag handles it
2052
+ // Reset allParticipants timeout counter when all participants respond
2053
+ if (context.consecutiveAllParticipantsTimeoutCount > 0) {
2054
+ logger.debug(`[AEM] Resetting allParticipants timeout count from ${context.consecutiveAllParticipantsTimeoutCount} to 0 (all participants responded)`)
2055
+ return { consecutiveAllParticipantsTimeoutCount: 0 }
2056
+ }
2057
+ return {}
2058
+ }),
2059
+ sendActionsTimeoutToAppM: assign(({ context, self }) => {
2005
2060
  const { nonResponders, msecs } = context.participantsActionTimeout
2006
2061
  const nonResponderIds = [...nonResponders]
2062
+ const allActiveParticipantIds = getActiveParticipantIds(context)
2063
+
2007
2064
  logger.info(`[AEM] allParticipants timeout expired after ${msecs}ms. Non-responders: ${nonResponderIds.join(', ')}`)
2008
2065
 
2066
+ // Track consecutive allParticipants timeouts where ALL participants timed out
2067
+ const allTimedOut = nonResponders.size === allActiveParticipantIds.length && allActiveParticipantIds.length > 0
2068
+ const updates = {}
2069
+
2070
+ if (allTimedOut) {
2071
+ const currentCount = context.consecutiveAllParticipantsTimeoutCount || 0
2072
+ const newCount = currentCount + 1
2073
+ logger.debug(`[AEM] All participants timed out in allParticipants timeout. Count: ${currentCount} -> ${newCount}`)
2074
+ updates.consecutiveAllParticipantsTimeoutCount = newCount
2075
+
2076
+ // Dead-man switch: if 4 consecutive allParticipants timeouts where all timed out, cancel event
2077
+ const maxConsecutiveTimeouts = C.DEAD_MAN_SWITCH.MAX_CONSECUTIVE_PARTICIPANT_TIMEOUTS
2078
+ if (newCount >= maxConsecutiveTimeouts) {
2079
+ logger.warn(`[AEM] Dead-man switch triggered: ${maxConsecutiveTimeouts} consecutive allParticipants timeouts where all participants timed out. Auto-canceling event.`)
2080
+ self.send({ type: 'CANCEL' })
2081
+ return updates
2082
+ }
2083
+ } else {
2084
+ // Not all timed out - reset the counter
2085
+ if (context.consecutiveAllParticipantsTimeoutCount > 0) {
2086
+ logger.debug(`[AEM] Resetting allParticipants timeout count from ${context.consecutiveAllParticipantsTimeoutCount} to 0 (not all participants timed out)`)
2087
+ updates.consecutiveAllParticipantsTimeoutCount = 0
2088
+ }
2089
+ }
2090
+
2009
2091
  if (context.appEventMachine) {
2092
+ // Send ACTIONS_TIMEOUT when timeout expires
2093
+ logger.info(`[AEM] Sending ACTIONS_TIMEOUT (timeout expired)`)
2010
2094
  context.appEventMachine.send({ type: 'ACTIONS_TIMEOUT', timeout: msecs, nonResponderIds })
2011
2095
  }
2012
- },
2096
+
2097
+ return updates
2098
+ }),
2013
2099
  sendActionsTimeoutToAppMCanceled: ({ context }) => {
2014
2100
  const { nonResponders, msecs } = context.participantsActionTimeout
2015
2101
  const nonResponderIds = [...nonResponders]
2016
2102
  logger.info(`[AEM] allParticipants timeout cancelled after ${msecs}ms. Non-responders at cancellation: ${nonResponderIds.join(', ')}`)
2017
2103
 
2018
2104
  if (context.appEventMachine) {
2105
+ // Send ACTIONS_TIMEOUT when timeout is canceled (backward compatibility)
2106
+ context.appEventMachine.send({
2107
+ type: 'ACTIONS_TIMEOUT',
2108
+ timeout: msecs,
2109
+ nonResponderIds,
2110
+ canceled: true
2111
+ })
2112
+ // Keep ACTIONS_TIMEOUT for backward compatibility (deprecated)
2019
2113
  context.appEventMachine.send({ type: 'ACTIONS_TIMEOUT', timeout: msecs, nonResponderIds, canceled: true })
2020
2114
  }
2021
2115
  },
@@ -16,6 +16,10 @@ module.exports = {
16
16
  RECONNECT: 30 * 1000, // 30 seconds
17
17
  BOOT_EXIT: 2 * 1000 // 5 seconds
18
18
  },
19
+ DEAD_MAN_SWITCH: {
20
+ // Maximum consecutive timeouts per participant before auto-canceling event
21
+ MAX_CONSECUTIVE_PARTICIPANT_TIMEOUTS: 4
22
+ },
19
23
  EventStates: {
20
24
  CREATED: 'created',
21
25
  STARTED: 'started',
@@ -365,9 +365,20 @@ class DisplayEventProcessor {
365
365
  if (timeoutFn) {
366
366
  // For participant displays, any non-null / positive timeout value should
367
367
  // start a real per-participant timer on that participant's APM.
368
- const msecs = timeoutFn(appCtx, participantStruct)
369
- if (typeof msecs === 'number' && msecs > 0) {
370
- timeout = msecs
368
+ if (!participantStruct) {
369
+ logger.warn(`[DisplayEventProcessor] Participant struct not found for ${participantId}, skipping timeout calculation`)
370
+ } else {
371
+ try {
372
+ const msecs = timeoutFn(appCtx, participantStruct)
373
+ if (typeof msecs === 'number' && msecs > 0) {
374
+ timeout = msecs
375
+ } else {
376
+ // Log when timeout function returns null/undefined to help diagnose issues
377
+ logger.debug(`[DisplayEventProcessor] Timeout function returned ${msecs === null ? 'null' : msecs === undefined ? 'undefined' : `non-numeric value: ${msecs}`} for ${participantId}`)
378
+ }
379
+ } catch (error) {
380
+ logger.warn(`[DisplayEventProcessor] Error calculating timeout for ${participantId}: ${error.message}`)
381
+ }
371
382
  }
372
383
  }
373
384
  const privateRenderContext = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {